fix(suspense): don't treat the leaving branch as the fallback while its mount is pending
Project: vuejs/core | Language: TypeScript | Concepts: error-handling, testing-strategy, refactoring, observability | Difficulty: mid
Link: https://github.com/vuejs/core/pull/15333
The problem
When using <Suspense timeout> inside a <Transition mode="out-in">, if a parent update happens while the fallback is waiting to mount (because the leave transition of the previous branch hasn't finished), Vue incorrectly patches the leaving branch as if it were the fallback. This causes the resolved content to never appear, and the fallback stays on screen permanently.
Why it's non-trivial
A naive fix might just try to patch the fallback as soon as a parent update happens, but this ignores the complex timing between transitions and Suspense. The fallback must only be mounted after the leave transition finishes; otherwise, the internal state becomes inconsistent, leading to lost content and broken transitions.
The approach
1. Skip patching the fallback while its mount is pending
Previously, the code would patch the fallback whenever a parent update arrived, even if the fallback wasn't yet mounted (i.e., the leave transition was still in progress):
- if (!isHydrating) {
+ if (!isHydrating && !suspense.isFallbackMountPending) {
patch(
activeBranch,
newFallback,
Annotation: This change adds a check for !suspense.isFallbackMountPending, ensuring that while the fallback's mount is deferred (i.e., waiting for the leave transition), Vue will not patch the leaving branch with the fallback vnode. This prevents the leaving branch from being hijacked as the fallback.
2. Only patch the fallback if it's not pending during dependency updates
Similarly, when Suspense dependencies change, patching the fallback is now also gated:
- } else {
+ } else if (!suspense.isFallbackMountPending) {
patch(
activeBranch,
newFallback,
Annotation: This prevents patching the fallback when its mount is still deferred, avoiding the same bug from a different code path.
3. When mounting the fallback, always use the latest vnode
If a parent update happens while the fallback mount is deferred, the fallback vnode might have changed. The code now ensures it always mounts the latest fallback:
+ // a parent update may have produced a newer fallback vnode while the
+ // mount was deferred (its patch is skipped during that window), so
+ // mount the latest one
+ const latestFallback = suspense.vnode.ssFallback!
// mount the fallback tree
patch(
null,
- fallbackVNode,
+ latestFallback,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized,
)
- setActiveBranch(suspense, fallbackVNode)
+ setActiveBranch(suspense, latestFallback)
Annotation: This ensures that if the fallback content was updated by a parent while waiting for the transition, the most recent version is what gets mounted, not a stale one.
4. Regression tests for both scenarios
Two new end-to-end tests were added:
- One where a parent update happens during the deferred fallback mount, ensuring the resolved branch is not dropped.
- One where the fallback content itself is updated during the defer, ensuring the latest fallback is mounted.
From the review thread
No notable review discussion on this PR.
Takeaways
-
Always gate UI updates on the correct state flags when dealing with asynchronous transitions.
Acting before a transition is complete can corrupt the component's state and UI. -
When deferring a mount, always use the freshest data or vnode at the time of the actual mount.
Parent updates can occur during the defer window, so stale references must be avoided. -
Regression tests for timing-sensitive UI bugs should simulate real user interactions and timing.
Automated end-to-end tests are essential for catching and preventing subtle race conditions in UI frameworks.