KeepAlive with max deactivates instead of unmounting the pruned current component when the render returned an already-mounted vnode (instance leaks)
Vue version
3.5.43 (the same code is on minor / 3.6)
Link to minimal reproduction
Steps to reproduce
- Click "Run".
- The parent first re-renders and returns the
<KeepAlive :max="1">vnode it rendered last time (already mounted), the way Nuxt's<NuxtPage>does while a parent route is kept alive. - It then switches the child from
AtoB(a new cache key), somax: 1prunesA.
- The parent first re-renders and returns the
- Look at the log.
What is expected?
A is unmounted: the log shows A unmounted.
What is actually happening?
The log only shows A deactivated. A is never unmounted:
isUnmountedstaysfalse, and its effect scope stays active.- It is moved into KeepAlive's storage container, but it is no longer in the cache, so nothing will ever unmount or reactivate it.
- Its watchers,
router.afterEachregistrations, and query observers keep running.onBeforeUnmount/onUnmounted/onScopeDisposenever run.
In our Nuxt app, every time the user leaves a kept-alive route and comes back to a different child, one more such instance piles up. Their watchers then misbehave, e.g. a leaked page's watcher calls router.replace and hijacks later navigations.
System Info
Any browser / happy-dom. Reproduced with vue 3.5.43 (Nuxt 4.5.2 in the original app).Any additional comments?
Cause.
- The render returns an already-mounted vnode (
vnode.elis set). KeepAlive clones it and stores the clone ascurrent. - KeepAlive then returns that clone, which still has
el.normalizeVNode(cloneIfMounted) inrenderComponentRootclones it once more, and it is this second clone that gets mounted (and cached bycacheSubtree). - When
pruneCacheEntrylater prunes the entry of the currently displayed component,isSameVNodeType(cached, current)is true. So it only callsresetShapeFlag(current), which clears the keep-alive flag on a vnode that is not in the tree. - The mounted vnode keeps
COMPONENT_SHOULD_KEEP_ALIVE, so the following patch deactivates it instead of unmounting it.
The same happens when the child is wrapped in <Suspense> (which is how <NuxtPage> renders it). There, cloneVNode of the Suspense vnode also clones ssContent.
Possible fix. In the else branch of pruneCacheEntry, also reset the flag on the cached vnode, which is the one actually mounted:
} else if (current) {
resetShapeFlag(current)
if (cached) resetShapeFlag(cached)
}We are applying this as a pnpm patch to @vue/[email protected]. The minimal reproduction above, and the same reproduction with <Suspense> around the child, then unmount A as expected. It also fixed the leak in the real app.
Setting current from the mounted subtree in cacheSubtree (onMounted / onUpdated) would probably also work. I'm happy to open a PR with a test for either approach.
Source: vuejs/core