Skip to content
118 changes: 118 additions & 0 deletions packages/runtime-core/__tests__/components/Suspense.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,124 @@ describe('Suspense', () => {
expect(calls).toEqual([])
})

// #10042
test('unmount suspensible child before parent suspense resolve', async () => {
const calls: string[] = []

const OuterB = defineAsyncComponent(
{
setup: () => {
onMounted(() => {
calls.push('OuterB mounted')
})
onUnmounted(() => {
calls.push('OuterB unmounted')
})
return () => h('div', 'OuterB')
},
},
10,
)

const OuterA = defineAsyncComponent(
{
setup: () => {
onMounted(() => {
calls.push('OuterA mounted')
})
onUnmounted(() => {
calls.push('OuterA unmounted')
})
return () =>
h('div', null, [
h('div', 'OuterA'),
h(RouterView, null, {
default: ({ Component }: any) => [
Component
? h(
Suspense,
{ suspensible: true },
{
default: () => h(Component),
},
)
: null,
],
}),
])
},
},
10,
)

const InnerA = defineAsyncComponent(
{
setup: () => {
onMounted(() => {
calls.push('InnerA mounted')
})
onUnmounted(() => {
calls.push('InnerA unmounted')
})
return () => h('div', 'InnerA')
},
},
5,
)

const toggle = ref(true)
const route = computed(() => {
return toggle.value ? [OuterA, InnerA] : [OuterB]
})

const Comp = {
setup() {
provide('route', route)
return () =>
h(RouterView, null, {
default: ({ Component }: any) => [
h(Suspense, null, {
default: () => h(Component),
}),
],
})
},
}

const root = nodeOps.createElement('div')
render(h(Comp), root)
await Promise.all(deps)
await nextTick()
expect(serializeInner(root)).toBe(`<!---->`)

await Promise.all(deps)
await nextTick()
expect(serializeInner(root)).toBe(
`<div><div>OuterA</div><div>InnerA</div></div>`,
)
expect(calls).toEqual([`OuterA mounted`, 'InnerA mounted'])

deps.length = 0
calls.length = 0
toggle.value = false
await nextTick()

// expect not change
expect(serializeInner(root)).toBe(
`<div><div>OuterA</div><div>InnerA</div></div>`,
)
expect(calls).toEqual([])

await Promise.all(deps)
await nextTick()
expect(serializeInner(root)).toBe(`<div>OuterB</div>`)
expect(calls).toEqual([
'OuterB mounted',
'InnerA unmounted',
'OuterA unmounted',
])
})

test('unmount suspense after resolve', async () => {
const toggle = ref(true)
const unmounted = vi.fn()
Expand Down
19 changes: 19 additions & 0 deletions packages/runtime-core/src/components/Suspense.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ export interface SuspenseBoundary {
isInFallback: boolean
isHydrating: boolean
isUnmounted: boolean
preEffects: Function[]
effects: Function[]
resolve(force?: boolean, sync?: boolean): void
fallback(fallbackVNode: VNode): void
Expand Down Expand Up @@ -506,6 +507,7 @@ function createSuspenseBoundary(
isInFallback: !isHydrating,
isHydrating,
isUnmounted: false,
preEffects: [],
effects: [],

resolve(resume = false, sync = false) {
Expand All @@ -526,6 +528,7 @@ function createSuspenseBoundary(
activeBranch,
pendingBranch,
pendingId,
preEffects,
effects,
parentComponent,
container,
Expand All @@ -536,6 +539,10 @@ function createSuspenseBoundary(
if (suspense.isHydrating) {
suspense.isHydrating = false
} else if (!resume) {
if (preEffects.length > 0) {
preEffects.forEach(e => e())
suspense.preEffects = []
}
delayEnter =
activeBranch &&
pendingBranch!.transition &&
Expand Down Expand Up @@ -901,3 +908,15 @@ function isVNodeSuspensible(vnode: VNode) {
const suspensible = vnode.props && vnode.props.suspensible
return suspensible != null && suspensible !== false
}

export function hasSuspensibleChild(vnode: VNode): boolean {
if (vnode.shapeFlag & ShapeFlags.SUSPENSE && isVNodeSuspensible(vnode)) {
return true
}

if (isArray(vnode.children)) {
return vnode.children.some(child => hasSuspensibleChild(child as VNode))
}

return false
}
19 changes: 17 additions & 2 deletions packages/runtime-core/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import { setRef } from './rendererTemplateRef'
import {
type SuspenseBoundary,
type SuspenseImpl,
hasSuspensibleChild,
queueEffectWithSuspense,
} from './components/Suspense'
import type { TeleportImpl, TeleportVNode } from './components/Teleport'
Expand Down Expand Up @@ -1583,8 +1584,22 @@ function baseCreateRenderer(
))

const update: SchedulerJob = (instance.update = () => {
if (effect.dirty) {
effect.run()
const job = () => {
if (effect.dirty) {
effect.run()
}
}

if (
__FEATURE_SUSPENSE__ &&
parentSuspense &&
parentSuspense.deps > 0 &&
instance.subTree &&
hasSuspensibleChild(instance.subTree)
) {
parentSuspense.preEffects.push(job)
} else {
job()
}
})
update.id = instance.uid
Expand Down