Skip to content

Suspense timeout + Transition out-in: parent update during the fallback swap permanently drops the resolved branch #15332

Description

@zingiberoff

Version

3.5.41

Reproduction link

play.vuejs.org/

Steps to reproduce

Open the playground with Vue 3.5.41 selected (the banner in the preview shows the actually running version). The repro is fully event-ordered (JS transition hooks hold the leave open), so it fails on the first run, every run:

  1. A keyed component inside <Suspense :timeout="100"> inside <Transition mode="out-in"> navigates to a cold async component (defineAsyncComponent, chunk not loaded yet).
  2. timeout elapses → Suspense starts the fallback swap; the old content's leave transition begins.
  3. While the leave is in progress, the parent re-renders (in a real app: a ResizeObserver callback, a store update — anything).
  4. The async component resolves; the leave finishes.

What is expected?

The resolved page is mounted (with the fallback shown in between).

What is actually happening?

@resolve fires and the module is loaded, but the page is never inserted — the fallback stays on screen forever. No error, no warning. A later unrelated parent re-render "un-sticks" it, which is why this is so hard to catch in the wild.

Removing any one of the three ingredients — the timeout, mode="out-in", or the mid-swap parent re-render — makes it pass (all three controls verified).

The bug is deterministic: 3.5.30 passes, 3.5.31+ fails (bisected).


Root cause (bisected + traced)

Regression introduced by 908c6ad (#9392, first released in 3.5.31), which added isFallbackMountPending:

  • fallback() sets isFallbackMountPending = true and defers mountFallback to the leaving branch's afterLeave.
  • A parent re-render hits patchSuspense's isInFallback path, which patches activeBranchnewFallback and calls setActiveBranch(suspense, newFallback) — mounting the fallback early without clearing isFallbackMountPending.
  • resolve() then skips unmount(activeBranch) because the flag is still true, yet hooks the content move onto activeBranch.transition.afterLeave — where activeBranch is now the fallback, which is never unmounted. The afterLeave never fires; the resolved branch is lost.

Before #9392, resolve() unconditionally unmounted the active branch, so the fallback left and its afterLeave inserted the content.

A guard in patchSuspense fixes it — while isFallbackMountPending is true, activeBranch is still the leaving content, so the patch(activeBranch, newFallback) + setActiveBranch(suspense, newFallback) calls in the isInFallback paths must be skipped. Happy to submit a PR with the fix and an e2e regression test (prepared and passing locally, including the #9392 test for #7966).

Real-world impact: in our production SPA (layout = RouterViewTransition out-inSuspense timeout, per the documented pattern) roughly 1 in 10 cold first navigations ended on an infinite spinner — console clean, all network requests succeed.

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: suspense

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions