Vue version
3.5.43 (the same code is on minor / 3.6)
Link to minimal reproduction
https://play.vuejs.org/#eNqFVF2P0zAQ/CujCl0SXWg4HktS6BWeQAgh4IUg1ZdsG6vJOnLstqcq/x05X+1VSDwlHu+uZ2bXPs9WdT0/WJotZnGTaVkbNGRsvUxZVrXSBmfktJVMa1XViolNiCLEZ6J6VcoDhWA6mR8y24dQ/JFEZuRBGMrd8idXynK30LRFi61WFbyDJS/llDPFjUGpdkjcvv/7TzCCldgTEvgsKgqQLFPGLQ//7EDAhYT9b0fdDzDs4CUj33eV3HnzgygtzWvbFP7m1dlVaJFfIjdBcKkwifhPvh3jrrI1GasZfWLhe7k8eCE2tdgRhrzNENx2GtrJgRWSzgXfW3kT+DiBjw6c3FI5DR564hJtZLYf4DdByiUZNEZpyl1mFOGL3BO+2pPxGsTu+03saLnAsSCGKUgTZANWppC8A9MRRqEp1DGENIO2xgXiwI5BB3JOmnKUoiNQ0Ujml6Qjkn838aZvL1ybeunU9MaPiNzCd9J7FEmSwGuMKMnD3d0gNRirjcr71J7T2tl5U+DJw3s8YoHVbRP7CkhQ+Ffjf0YlTgs8oA1R+GsH7Ol5gfXctRdtME5D2/U3TLntOieaZ86wtZwZqRja8mhAFOFhDk2vezM7g2uhu6vXc3Ht6FBNB6lsUz5fjPdFqUnkzximMeib4wpfSx2dcvjF2ft7txZHIc10r/1gIPV2juYoTVa4MRBuLEg7re9GB2ptmZreuJdnPXkptynHUf/GLJ0BsaGqLoWh7nLHT9YYxfiQlTLbJ+lMW05ny++W46jf6sO6MYr6/3r56VRTZihfYOVGdbqCc3xRu0Uc1UOgpuX53L00bRtHbunIXBGYtX8Bl9GrmA==
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
A to B (a new cache key), so max: 1 prunes A.
- 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:
isUnmounted stays false, 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.afterEach registrations, and query observers keep running. onBeforeUnmount / onUnmounted / onScopeDispose never 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.el is set). KeepAlive clones it and stores the clone as current.
- KeepAlive then returns that clone, which still has
el. normalizeVNode (cloneIfMounted) in renderComponentRoot clones it once more, and it is this second clone that gets mounted (and cached by cacheSubtree).
- When
pruneCacheEntry later prunes the entry of the currently displayed component, isSameVNodeType(cached, current) is true. So it only calls resetShapeFlag(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/runtime-core@3.5.43. 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.
Vue version
3.5.43 (the same code is on
minor/ 3.6)Link to minimal reproduction
https://play.vuejs.org/#eNqFVF2P0zAQ/CujCl0SXWg4HktS6BWeQAgh4IUg1ZdsG6vJOnLstqcq/x05X+1VSDwlHu+uZ2bXPs9WdT0/WJotZnGTaVkbNGRsvUxZVrXSBmfktJVMa1XViolNiCLEZ6J6VcoDhWA6mR8y24dQ/JFEZuRBGMrd8idXynK30LRFi61WFbyDJS/llDPFjUGpdkjcvv/7TzCCldgTEvgsKgqQLFPGLQ//7EDAhYT9b0fdDzDs4CUj33eV3HnzgygtzWvbFP7m1dlVaJFfIjdBcKkwifhPvh3jrrI1GasZfWLhe7k8eCE2tdgRhrzNENx2GtrJgRWSzgXfW3kT+DiBjw6c3FI5DR564hJtZLYf4DdByiUZNEZpyl1mFOGL3BO+2pPxGsTu+03saLnAsSCGKUgTZANWppC8A9MRRqEp1DGENIO2xgXiwI5BB3JOmnKUoiNQ0Ujml6Qjkn838aZvL1ybeunU9MaPiNzCd9J7FEmSwGuMKMnD3d0gNRirjcr71J7T2tl5U+DJw3s8YoHVbRP7CkhQ+Ffjf0YlTgs8oA1R+GsH7Ol5gfXctRdtME5D2/U3TLntOieaZ86wtZwZqRja8mhAFOFhDk2vezM7g2uhu6vXc3Ht6FBNB6lsUz5fjPdFqUnkzximMeib4wpfSx2dcvjF2ft7txZHIc10r/1gIPV2juYoTVa4MRBuLEg7re9GB2ptmZreuJdnPXkptynHUf/GLJ0BsaGqLoWh7nLHT9YYxfiQlTLbJ+lMW05ny++W46jf6sO6MYr6/3r56VRTZihfYOVGdbqCc3xRu0Uc1UOgpuX53L00bRtHbunIXBGYtX8Bl9GrmA==
Steps to reproduce
<KeepAlive :max="1">vnode it rendered last time (already mounted), the way Nuxt's<NuxtPage>does while a parent route is kept alive.AtoB(a new cache key), somax: 1prunesA.What is expected?
Ais unmounted: the log showsA unmounted.What is actually happening?
The log only shows
A deactivated.Ais never unmounted:isUnmountedstaysfalse, and its effect scope stays active.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.replaceand 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.
vnode.elis set). KeepAlive clones it and stores the clone ascurrent.el.normalizeVNode(cloneIfMounted) inrenderComponentRootclones it once more, and it is this second clone that gets mounted (and cached bycacheSubtree).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.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,cloneVNodeof the Suspense vnode also clonesssContent.Possible fix. In the
elsebranch ofpruneCacheEntry, also reset the flag on the cached vnode, which is the one actually mounted:We are applying this as a
pnpm patchto@vue/runtime-core@3.5.43. The minimal reproduction above, and the same reproduction with<Suspense>around the child, then unmountAas expected. It also fixed the leak in the real app.Setting
currentfrom the mounted subtree incacheSubtree(onMounted / onUpdated) would probably also work. I'm happy to open a PR with a test for either approach.