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:
- A keyed component inside
<Suspense :timeout="100"> inside <Transition mode="out-in"> navigates to a cold async component (defineAsyncComponent, chunk not loaded yet).
timeout elapses → Suspense starts the fallback swap; the old content's leave transition begins.
- While the leave is in progress, the parent re-renders (in a real app: a ResizeObserver callback, a store update — anything).
- 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 activeBranch → newFallback 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 = RouterView → Transition out-in → Suspense timeout, per the documented pattern) roughly 1 in 10 cold first navigations ended on an infinite spinner — console clean, all network requests succeed.
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:
<Suspense :timeout="100">inside<Transition mode="out-in">navigates to a cold async component (defineAsyncComponent, chunk not loaded yet).timeoutelapses → Suspense starts the fallback swap; the old content's leave transition begins.What is expected?
The resolved page is mounted (with the fallback shown in between).
What is actually happening?
@resolvefires 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()setsisFallbackMountPending = trueand defersmountFallbackto the leaving branch'safterLeave.patchSuspense'sisInFallbackpath, which patchesactiveBranch→newFallbackand callssetActiveBranch(suspense, newFallback)— mounting the fallback early without clearingisFallbackMountPending.resolve()then skipsunmount(activeBranch)because the flag is still true, yet hooks the content move ontoactiveBranch.transition.afterLeave— whereactiveBranchis now the fallback, which is never unmounted. TheafterLeavenever fires; the resolved branch is lost.Before #9392,
resolve()unconditionally unmounted the active branch, so the fallback left and itsafterLeaveinserted the content.A guard in
patchSuspensefixes it — whileisFallbackMountPendingis true,activeBranchis still the leaving content, so thepatch(activeBranch, newFallback)+setActiveBranch(suspense, newFallback)calls in theisInFallbackpaths 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 =
RouterView→Transition out-in→Suspense timeout, per the documented pattern) roughly 1 in 10 cold first navigations ended on an infinite spinner — console clean, all network requests succeed.