Skip to content

KeepAlive with max deactivates instead of unmounting the pruned current component when the render returned an already-mounted vnode (instance leaks) #15710

Description

@AibanHoriuchi

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

  1. 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.
  2. 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.

  1. The render returns an already-mounted vnode (vnode.el is set). KeepAlive clones it and stores the clone as current.
  2. 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).
  3. 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.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    🔨 p3-minor-bugPriority 3: this fixes a bug, but is an edge case that only affects very specific usage.scope: keep-alive

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions