Skip to content

fix(Suspense): delay update nested suspensible child if parent suspense is not resolved #10066

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
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