Skip to content

KeepAlive caches a never-mounted vnode when a switch lands while an ancestor Suspense is pending #15288

Description

@drubot

Vue version

3.5.41 (also reproduces on 3.5.40 and 3.6.0-rc.3)

Link to minimal reproduction

https://play.vuejs.org/#eNqNVs1u4zYQfpWBL6RQ2c42uz0YtoFsemhRtLtIc1vtgZHGFhtqSJCUEiMQ0IfoE/ZJCpKyJTsbbC8SNDOc75tf6mV2Y8yia3G2mq1daaXx4NC3ZluQbIy2Hl6gwp0kvNWN0YTkc6hzcLVQSj/d4S4HGx6aftcteaxy2KO/ba1F8r+S84JKzOHP1hkkhzn8hmhulOwQethZ3QDrWmQFFVRqch4qVOIAG+CNy2CzBcIn+Gx1Ix1ybqPIob+XDerWc5tD47JsPK70HjaBEv/yNTsK0Vpt3Wu5qKqIFL0qvV90QrW4MK2reROdLpeRsANfI9x9+nQP62MoWzBIlaQ97LSF66urxsG/f/8DkuCP9tmDr6UD6UAYM1fYoYrehDtQmXIMy+jVeanU3LZEwVd9qKzwUhPoXVRLkl4KBUbscXEkfqe1/xkNbC6Lw18KAiDR4ArYYMXyIJsA8wyiGYB4EnJIOb++usqS1KJvLQGPaak5q2THcmBW62BrwKKoDiwa93lB/ST9jXjEz2KPIa2BRXAR7L5JMxHNByrf4nfO8N2R4NsUkSq0WMVsrYDBDxFiONZHqCndQPUGNifanEUJy6YGH18ZfGQTHyaFO04Ejz5OLh7xcK5mgh176z7W37alby2mtkkhuFXUw7TdeChABltYn2ZoG74mFgZt5BOsIq/ymPFT57REaL/XONEo9c1FRaaJPxaj5uN4U6tUPpYvVl60yq/Oj6RjQ9m+TKVBPjRull8qToHn8AKNeF7BB+jP4L4H+ibjizNQ8zhxcSME9SMeVuGRJNBn0F/yuxB8HT/7i2F5vSJ5thDG3Gry+OwXpaad3C/i3vpFUKViyTjatADTPptuK7R20aBzofZja5LoYDNMFlfiAVUeOyKHx+gn5m0MEzZRG4RjoBt4jOtjMoc/pTkUVZWcZgX1AfR0B/ABcgQ5XzTZKCLRcfZuEd5yLzyCgPkWHuCplgrjAoxrZ9Lk0qWVeVy/LE9TmgN7YNkl2PurkSz7cfHKm0WnVYdVXN3j3VSKssYq4hN2aOdNigw60hWyywCuJwE8hADEwOomhzTuZ+bvX8cb4Gvp00VjtHSasEo0AMnbw1thRocf/g9+yEClKbIPbbhepht/G2q39tgYJTzGaVkPa2KZvmqb3sbi9uUl3pR/aUmcFQWxDPp+vQyqow10c7nbFLOhTRXS3tfFDJw/KNwUs1IrbVdQWtk4TcUs+Dyzhb4f9mCSc5etChqt3gaHbo7KYcIPPCWVqq3QnUKfbUmfOXfrZfjdWS8nGZj1/wEDZfRV

Steps to reproduce

The reproduction drives itself from onMounted — just open it and read the log. The tree is the one Nuxt renders for a page with <NuxtPage keepalive>:

<Suspense>            <!-- root boundary, still pending -->
  <RootDep />         <!-- async setup, resolves after 300ms -->
  <KeepAlive>
    <Suspense>        <!-- per-page boundary -->
      <component :is="page" :key="key" />
    </Suspense>
  </KeepAlive>
</Suspense>
  1. While RootDep is still pending (so parentSuspense.deps > 0), switch the inner page from PageA to PageB.
  2. Let the root boundary resolve.
  3. Switch back to PageA, then to PageB again.

What is expected?

The inner page follows the switches, and no errors are raised.

What is actually happening?

The rendered page is stuck on PageA forever — every later switch is a no-op — and the renderer throws:

Cannot read properties of null (reading 'suspenseId')   // patchSuspense
Cannot read properties of null (reading 'm')            // unmountComponent

In a production build the second one is the more familiar
Uncaught (in promise) TypeError: Cannot destructure property 'bum' of 'instance' as it is null.

Removing the <KeepAlive> and changing nothing else makes both errors go away and navigation work.

System Info

  System:
    OS: Linux 7.0 Ubuntu 26.04 LTS
    CPU: (4) x64 Intel(R) Core(TM) i7-8700 CPU @ 3.20GHz
    Memory: 5.86 GB / 11.67 GB
  Binaries:
    Node: 24.19.0
    npm: 11.19.0
  npmPackages:
    vue: 3.5.41
  Browsers:
    Chromium (Playwright 1.5x, headless)

Any additional comments?

The chain, as far as I could trace it:

  1. The route change re-renders KeepAlive, so the inner <Suspense> is patched with n1 != null. Because an ancestor boundary is still pending, SuspenseImpl.process takes its early return:

    if (parentSuspense && parentSuspense.deps > 0 && !n1.suspense.isInFallback) {
      n2.suspense = n1.suspense
      n2.suspense.vnode = n2
      n2.el = n1.el
      return
    }

    The incoming page vnode is therefore never patched: vnode.component === null, vnode.el === null.

  2. KeepAlive's onUpdatedcacheSubtree still caches it, unconditionally:

    if (isSuspense(instance.subTree.type)) {
      queuePostRenderEffect(() => {
        cache.set(pendingCacheKey, getInnerChild(instance.subTree))
      }, instance.subTree.suspense)
    }
  3. The next render for that key hits the poisoned entry: KeepAlive copies cachedVNode.component (null) onto the new vnode and sets COMPONENT_KEPT_ALIVE, so patchSuspense reads newBranch.component.suspenseId on null, and the teardown that follows calls unmountComponent(null).

Suggested fix — the same shape as the two guards already at that site, i.e. do not cache a vnode that never finished mounting:

const innerChild = getInnerChild(instance.subTree)
if (innerChild.component) {
  cache.set(pendingCacheKey, innerChild)
}

With that guard the page is simply not cached that one time, and the reproduction runs clean.

This looks like a third path into the defect class fixed by #10912 (avoid caching vnode that not been mounted, for #10899) and #11479 (for #6028) — both guard KeepAlive's own subtree, while here it is an ancestor Suspense that is pending.

Real-world context: this is how it surfaced for us — a Nuxt 4 app rendering <NuxtPage :keepalive="{ max: 5 }" />, where a link clicked before hydration finished intermittently left the user on a blank page with Cannot destructure property 'bum' of 'e' as it is null in the console. The window widens with slower hydration, so it shows up under load / on slow clients.

Investigation and reproduction prepared with Claude Code, on behalf of drunomics.

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