Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions packages/runtime-core/src/components/Suspense.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,13 @@ function patchSuspense(
// because we aren't actually showing a fallback content when
// patchSuspense is called. In such case, patch of fallback content
// should be no op
if (!isHydrating) {
// The same applies while the fallback mount is deferred to the leaving
// branch's afterLeave (out-in transition): activeBranch is still the
// leaving content, not the fallback. Patching it with the fallback here
// would hijack activeBranch, and resolve() would then attach the
// content move to an afterLeave that never fires, permanently dropping
// the resolved branch.
if (!isHydrating && !suspense.isFallbackMountPending) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
patch(
activeBranch,
newFallback,
Expand Down Expand Up @@ -316,7 +322,9 @@ function patchSuspense(
)
if (suspense.deps <= 0) {
suspense.resolve()
} else {
} else if (!suspense.isFallbackMountPending) {
// while the fallback mount is deferred to the leaving branch's
// afterLeave, activeBranch is still the leaving content — see above
patch(
activeBranch,
newFallback,
Expand Down Expand Up @@ -666,10 +674,14 @@ function createSuspenseBoundary(
if (!suspense.isInFallback) {
return
}
// 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,
Expand All @@ -678,7 +690,7 @@ function createSuspenseBoundary(
slotScopeIds,
optimized,
)
setActiveBranch(suspense, fallbackVNode)
setActiveBranch(suspense, latestFallback)
}

const delayEnter =
Expand Down
150 changes: 150 additions & 0 deletions packages/vue/__tests__/e2e/Transition.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2169,6 +2169,156 @@ describe('e2e: Transition', () => {
E2E_TIMEOUT,
)

// a parent update arriving while the timed-out fallback swap is waiting
// for the leaving branch's afterLeave must not drop the resolved branch
test(
'parent update during timed-out fallback swap with out-in transition',
async () => {
await page().evaluate(() => {
const { createApp, shallowRef, ref, h, defineAsyncComponent } = (
window as any
).Vue
const One = {
setup() {
return () => h('div', { class: 'test' }, 'one')
},
}
const Two = {
async setup() {
return () => h('div', { class: 'test' }, 'two')
},
}
const AsyncTwo = defineAsyncComponent(
() =>
new Promise(res => {
;(window as any).resolveTwo = () => res(Two as any)
}),
)
createApp({
template: `
<div id="container">
<transition mode="out-in" :duration="300">
<Suspense :timeout="10">
<component :is="view" :key="name" :data-tick="tick"/>
<template #fallback><div class="fallback">loading</div></template>
</Suspense>
</transition>
</div>
<button id="toggleBtn" @click="click">button</button>
<button id="tickBtn" @click="tick++">tick</button>
`,
setup: () => {
const view = shallowRef(One)
const name = ref('one')
const tick = ref(0)
const click = () => {
view.value = AsyncTwo
name.value = 'two'
}
return { view, name, tick, click }
},
}).mount('#app')
})
await transitionFinish()
expect(await html('#container')).toBe(
'<div class="test" data-tick="0">one</div>',
)

// navigate to the cold async branch; timeout=10 starts the fallback
// swap and the old branch's leave transition
await click('#toggleBtn')
await timeout(10 + buffer)
// unrelated parent re-render while the fallback mount is deferred to
// the leaving branch's afterLeave
await click('#tickBtn')
// the async branch resolves while the leave is still in progress
await page().evaluate(() => (window as any).resolveTwo())
await transitionFinish(300)
await transitionFinish(300)
await nextTick()
expect(await html('#container')).toContain('two')
expect(await html('#container')).not.toContain('loading')
},
E2E_TIMEOUT,
)

// a parent update that changes the fallback content while its mount is
// deferred must mount the latest fallback, not the stale one
test(
'fallback updated by parent while its mount is deferred',
async () => {
await page().evaluate(() => {
const { createApp, shallowRef, ref, h, defineAsyncComponent } = (
window as any
).Vue
const One = {
setup() {
return () => h('div', { class: 'test' }, 'one')
},
}
const Two = {
async setup() {
return () => h('div', { class: 'test' }, 'two')
},
}
const AsyncTwo = defineAsyncComponent(
() =>
new Promise(res => {
;(window as any).resolveTwo = () => res(Two as any)
}),
)
createApp({
template: `
<div id="container">
<transition mode="out-in" :duration="300">
<Suspense :timeout="10">
<component :is="view" :key="name" :data-tick="tick"/>
<template #fallback><div class="fallback">loading {{ tick }}</div></template>
</Suspense>
</transition>
</div>
<button id="toggleBtn" @click="click">button</button>
<button id="tickBtn" @click="tick++">tick</button>
`,
setup: () => {
const view = shallowRef(One)
const name = ref('one')
const tick = ref(0)
const click = () => {
view.value = AsyncTwo
name.value = 'two'
}
return { view, name, tick, click }
},
}).mount('#app')
})
await transitionFinish()
expect(await html('#container')).toBe(
'<div class="test" data-tick="0">one</div>',
)

// navigate to the cold async branch; the fallback mount is deferred
// to the old branch's afterLeave
await click('#toggleBtn')
await timeout(10 + buffer)
// parent update changes the fallback content while its mount is
// still deferred
await click('#tickBtn')
// let the leave finish so the (latest) fallback mounts
await transitionFinish(300)
await nextTick()
expect(await html('#container')).toContain('loading 1')
// the async branch resolves after the leave completed
await page().evaluate(() => (window as any).resolveTwo())
await transitionFinish(300)
await transitionFinish(300)
await nextTick()
expect(await html('#container')).toContain('two')
expect(await html('#container')).not.toContain('loading')
},
E2E_TIMEOUT,
)

// #14640
test(
'switch suspense branches after teleport updates before pending mount finishes',
Expand Down
Loading