Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

keep-alive component in nested route result in child route mounted twice #626

Open
LiHDong opened this issue Dec 1, 2020 · 42 comments
Open
Labels
external This depends on an external dependency but is kept opened to track it help wanted Extra attention is needed

Comments

@LiHDong
Copy link

LiHDong commented Dec 1, 2020

Version

3.0.3

Reproduction link

https://codesandbox.io/s/nifty-roentgen-67uyr
without vue router

Steps to reproduce

There is 5 files in the project. These files are App.vue, UserCenter/Index.vue, UserCenter/Push.vue, List/Index.vue, List/Detail.vue. Two Index.vue files are the child routes of App.vue, and I wrote keep-alive in App.vue. Push.vue and Detail.vue are child routes of two Index.vue, and I wrote keep-alive in two Index.vue to cache them.Here is the step to reproduce:

  1. First, open the sandbox link and open console, you will find the console print 'app loading';
  2. Second, click 'push page' link, console print 'user center loading... ' and 'push loading... ';
  3. Third, click 'detail page' link, console print 'list loading...' and 'detail loading...' twice;
  4. Forth, navigate back to previous push page, console print 'push loading' again.

What is expected?

In step 3, I just expect it print once; In step4, it's not supposed to print again;

What is actually happening?

In these circumstance, I found the child route mount twice;And about step 4 in my project, I found the cache did function, but it did mount again, which was confusing.

@edison1105
Copy link
Member

It seems like a Vue-router bug.

@LiHDong
Copy link
Author

LiHDong commented Dec 1, 2020

It seems like a Vue-router bug.

I'm sorry that I cannot figure out where the problem is. Look forward to an avaible solution.Thanks!

@posva posva transferred this issue from vuejs/core Dec 1, 2020
@posva
Copy link
Member

posva commented Dec 1, 2020

@LiHDong moved to vue-router repo for the moment

The problem comes from the nested router-view inside UserCenter: because it's kept alive, it reacts to route changes and tries to render with the new nested view. I will see if there is a way to prevent this.

@posva
Copy link
Member

posva commented Dec 1, 2020

The problem is the same as vuejs/vue#8819 which I don't know if it's expected or not. @yyx990803 is it normal for an inactive kept-alive component to keep rendering while inactive?

In the context of vue-router I tried internally avoiding rendering the router-view when the component is inactive, but it's too late, it still gets to mount the children once, resulting in mounting two Detail pages. So I tried not changing the route for nested router views but it turns out the onDeactivated hook triggers after computed based on the current route location, not allowing me to pass the old version of a route

// getting the global route or a route injected by a parent router-view
const injectedRoute = inject(routerViewLocationKey, inject( routeLocationKey));
onDeactivated(() => {
    console.log('deactivated', depth)
})
const myRoute = computed(() => {
    console.log('computing myRoute')
    return (props.route || unref(injectedRoute))
})
// providing the route to nested router-view
provide(routerViewLocationKey, myRoute)

This prints computing myRoute and then deactivated. If it was the other way around, I could have cached the previous value of myRoute.

So far I don't see a way to handle this a part from manually deactivating any critical watcher with a variable that is toggled inside onDeactivated()

edit: Opened issue on Vue Core looking for guidance
tldr: in this example the page changes after the new component is rendered. That where the router is currently blocked to fix this

@posva posva added the help wanted Extra attention is needed label Dec 22, 2020
@danitatt

This comment has been minimized.

@ghost ghost mentioned this issue Jan 28, 2021
@posva posva added the external This depends on an external dependency but is kept opened to track it label Mar 1, 2021
@emiyalee1005

This comment has been minimized.

@edgexie

This comment has been minimized.

@x-255

This comment has been minimized.

@XiaoRIGE

This comment has been minimized.

@CNMathon

This comment has been minimized.

@nicolas-t
Copy link

nicolas-t commented Jan 3, 2022

Hello, Happy new year everyone !

This issue affects performance and kind of defeats the purpose of kept-alive router-views on vue 3.
For example (extreme demo based on a real life observation):

Using keep alive: we see the double mount.
Total time : 2.08s
image

Not using keep alive: single mount.
Total time : 1.11s
image

We can see here that changing page is twice slower if router-view is wrapped in keep-alive tag.
Worth noting that using keep-alive in vue 2 in the exact same scenario we get a total time of around 0.7s, 3x faster :(
(I can share a reproduction if needed)


It also seems that the activated event is triggered on the component that gets deactivated.
Reproduction here : https://github.com/nicolas-t/vue-3-keep-alive-lifecycle
Demo here : https://vue-3-keep-alive-lifecycle.netlify.app/home/nested

Related topic : https://forum.vuejs.org/t/vue-3-keep-alive-lifecycle-issue/125549


I know it's been more than a year since this issue has been opened, but is there anything we can do to help ?
It's blocking me and maybe others to migrate from vue 2 to vue 3 at the moment.

Thanks :)

@danielroe
Copy link
Member

This is also true when using nested <Suspense> rather than <KeepAlive>: https://codesandbox.io/s/lively-mountain-o395nx. It feels like the cause is likely similar, but I'm happy to open a new issue if you think not.

@ericloud
Copy link

ericloud commented Apr 8, 2022

Hi,
I have a similar issue with two nested QRouteTab component (from Quasar).
Reproduction link: https://codesandbox.io/s/without-qtabpanel-m43nec
If you click ont "test B" tab, the setup of test j is executed twice.

Console output of navigation between tabs: Test A (test i) -> Test B (test j)

[Quasar] Running SPA. 
##### setup -> index 
##### setup -> layout test A 
##### setup -> test i 
##### setup -> layout test B 
##### setup -> test j  
##### setup -> test j  

Also it seems that child component need to be mounted twice before to "keep-alive correctly".
Example: if you comeback on test A the setup of "test i" is rerun. Then, you can navigate between both tabs without additional mount:

Console output of navigation between tabs: Test A (test i) -> test ii -> Test B (test j) -> Test A (test i) -> test ii -> test iii

[Quasar] Running SPA. 
##### setup -> index 
##### setup -> layout test A 
##### setup -> test I                          # first time
##### setup -> test ii                         # first time
##### setup -> layout test B 
##### setup -> test j 
##### setup -> test j  
##### setup -> test i                           # second time
##### setup -> test ii                          # second time
##### setup -> test iii
##### setup -> test iii

If you think this issue can be fixe with a cleaner code (good practice), I will thank you in advance for your help.

@rubick24
Copy link

I made a minimal reproducible example using vue-router@4.0.15 here: https://codesandbox.io/s/gifted-gagarin-g3ugux?file=/src/main.js
It contains two child router-view, one uses keep-alive and the other one does not, and when navigating from /a/a to /b/a, the setup function in BA.vue logged twice.

@posva
Copy link
Member

posva commented Feb 27, 2023

This hasn't advanced yet as noted in #626 (comment).

@gwl002

This comment was marked as spam.

@emiyalee1005
Copy link

emiyalee1005 commented Aug 29, 2023

I created a custom keep-alive component as workaround fix to this bug:
vue3-keep-alive-component (https://github.com/emiyalee1005/vue3-keep-alive-component)

For anyone who encounter the issue can have a try on this

@peteclark82
Copy link

peteclark82 commented Oct 7, 2023

The Problem

After some investigation, it seems this bug is down to a fairly complex interaction between KeepAlive and RouterView when using nested routes.

Also, the bug results in the child route component being mounted for EVERY RouterView instance in pages at that same depth, not necessarily just TWICE.

Example

So, for example, if we have the following structure....

Page A
-- Page A-A
Page B
-- Page B-A
Page C
--Page C-A

Performing the following steps in-order will produce these results:

  1. Navigate to Page A-A:
    Mounts "Page A-A" component within "Page A" (as expected)
  2. Navigate to Page B-A:
    Mounts "Page B-A" component within "Page B" (as expected)
    Mounts "Page B-A" component within "Page A" (NOT as expected)
  3. Navigate to Page C-A:
    Mounts "Page C-A" component within "Page C" (as expected)
    Mounts "Page C-A" component within "Page A" (NOT as expected)
    Mounts "Page C-A" component within "Page B" (NOT as expected)

Explanation

This seems to be because each nested RouterView component is kept alive. This results in all nested RouterView components continuing to update even when they are deactivated. In and of itself this wouldn't be too bad, but unfortunately the RouterView component doesn't distinguish between which parent RouterView instantiated it, therefore in the above scenario, the RouterView components in "Page A", "Page B" and "Page C" all attempt to render all pages at their depth, e.g. "Page A-A", "Page B-A" and "Page C-A".

Workaround Fix Component (FixedRouterView.vue)

I have come up with a fairly solid hack which works around the above mentioned issue which I have include in the codesandbox as "FixedRouterView.vue".

The fix allows each nested RouterView to determine which section of the router config it is responsible for and ensures that it only renders page components from within that section.

https://codesandbox.io/s/brave-river-yq7r6v?file=/src/components/FixedRouterView.vue

To use the codesandbox demo:

Reproduce Issue
Open the console
Click through the pages at the top and see the duplicate console.log messages

With Fix Applied
Reload the page
Toggle the "Apply Fix" checkbox (which enables the FixedRouterView logic)
Now again click through the pages at the top and you will not see any duplicate console.log messages

UPDATE: Ran into a bug in production when using the previous FixedRouterView component provided in the codesandbox. The issue centered around timing, navigating before nested routers had fully loaded resulted in the FixedRouterView "remembering" the wrong config.

I have now fixed the problem, dramatically simplified the code/approac. All of which is updated in the above linked codesandbox.

@danielroe
Copy link
Member

danielroe commented Oct 11, 2023

We have to do something very similar in Nuxt even without <KeepAlive>. Suggestions or better implementations are very welcome. 🙏

@Vissie2
Copy link

Vissie2 commented Nov 9, 2023

@peteclark82's component is a very useful workaround. If anyone is looking for a TypeScript variant, it's below.

See code
<template>
  <RouterView v-slot="{ Component, route }">
    <slot
      v-bind="{
        Component: getComponent(Component, route),
        route,
      }"
    />
  </RouterView>
</template>

<script setup lang="ts">
import {
  type RouteLocationNormalizedLoaded,
  type RouteRecordName,
  type RouteRecordRaw,
  RouterView,
  useRoute,
} from 'vue-router';
import { type VNode, inject, onBeforeMount, provide, ref } from 'vue';

type RouterViewEntry = {
  children: RouteRecordRaw[] | undefined;
  depth: number;
  name: RouteRecordName | undefined;
};

const currentRoute = useRoute();

const childRoutesDict: Record<string, RouteRecordName> = {};

const storedComponent = ref<VNode | null>(null);
const currentRouterView = ref<RouterViewEntry | null>(null);

const parentRouterView = inject<RouterViewEntry | null>(
  'parentRouterView',
  null
);

provide('parentRouterView', currentRouterView);

function setRouterView() {
  const depth = getRouterViewDepth();
  const matchedRoute = currentRoute.matched[depth];

  currentRouterView.value = {
    children: matchedRoute?.children,
    depth,
    name: matchedRoute?.name,
  };
}

function getRouterViewDepth() {
  if (typeof parentRouterView?.depth === 'undefined') {
    return 0;
  }

  return parentRouterView.depth + 1;
}

function getComponent(component: VNode, route: RouteLocationNormalizedLoaded) {
  if (component) {
    storeChildComponent(component, route);
    tryToOverrideStoredComponent(component);
  }

  return component;
}

function storeChildComponent(
  component: VNode,
  route: RouteLocationNormalizedLoaded
) {
  const key = getComponentKey(component);

  if (!key) {
    console.warn('Invalid key. Buggy behavior is happening.');
    return;
  }

  if (!childRoutesDict[key]) {
    const currentRouterViewDepth = currentRouterView.value?.depth ?? 0 + 1;
    const childComponentRouteMatch = route.matched[currentRouterViewDepth];
    const routeName = childComponentRouteMatch?.name;

    if (routeName) {
      childRoutesDict[key] = routeName;
    }
  }
}

function tryToOverrideStoredComponent(component: VNode) {
  if (isComponentOfCurrentRouterView(component)) {
    storedComponent.value = component;
  }
}

function isComponentOfCurrentRouterView(component: VNode) {
  const key = getComponentKey(component);

  if (!key || !currentRouterView.value?.children) {
    return false;
  }

  const isMatch = currentRouterView.value.children.some(({ name }) => {
    return name === childRoutesDict[key];
  });

  return isMatch;
}

function getComponentKey(component: VNode) {
  if (typeof component.type === 'object') {
    if ('__name' in component.type) {
      return component.type.__name;
    } else if ('name' in component.type) {
      return component.type.name;
    }
  }
}

onBeforeMount(() => {
  setRouterView();
});
</script>

@Sytten
Copy link

Sytten commented Feb 12, 2024

@posva Considering the comment from @peteclark82, do you still believe this is due to vuejs/vue#8819?

@Fitz6
Copy link

Fitz6 commented Jul 20, 2024

@posva @yyx990803 This issue has been existing for three and a half years without any progress. It significantly affects multi-layout routing switching in mobile web applications and should not have been ignored for so long.

@duanluan
Copy link

duanluan commented Nov 1, 2024

My issue mainly lies in the multiple triggers of onMounted.
I’m sharing a rough approach here as a reference for discussion.
from: duanluan/wuyou-boot-ui@37d2dd1

src/util/debounceLifecycle.ts:

import {onMounted} from 'vue'
import * as CryptoJS from 'crypto-js';

const debounceMap: Map<string, number> = new Map();

const debounceExecution = (callback, delay = 300) => {
  const key = CryptoJS.SHA256(callback.toString()).toString()

  if (debounceMap.has(key)) {
    clearTimeout(debounceMap.get(key))
    debounceMap.delete(key)
  }
  const timeout = setTimeout(() => {
    debounceMap.delete(key)
    callback()
  }, delay)
  debounceMap.set(key, timeout)
}

const onDebounceMounted = (callback, delay = 300) => {
  onMounted(() => {
    debounceExecution(callback, delay)
  })
}

export {onDebounceMounted}

src/view/sys/RolesView.vue

<script setup lang="ts">
import {onDebounceMounted} from "@/utils/debounceLifecycle.ts";

onDebounceMounted(async () => {
  search()
})
<script>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
external This depends on an external dependency but is kept opened to track it help wanted Extra attention is needed
Projects
Status: 💬 In discussion
Development

No branches or pull requests