diff --git a/packages/runtime-core/__tests__/components/Suspense.spec.ts b/packages/runtime-core/__tests__/components/Suspense.spec.ts index b748f52011f..80d213fc790 100644 --- a/packages/runtime-core/__tests__/components/Suspense.spec.ts +++ b/packages/runtime-core/__tests__/components/Suspense.spec.ts @@ -16,7 +16,8 @@ import { watchEffect, onUnmounted, onErrorCaptured, - shallowRef + shallowRef, + Fragment } from '@vue/runtime-test' import { createApp } from 'vue' @@ -1257,4 +1258,146 @@ describe('Suspense', () => { `A component with async setup() must be nested in a ` ).toHaveBeenWarned() }) + + test('nested suspense with suspensible', async () => { + const calls: string[] = [] + let expected = '' + + const InnerA = defineAsyncComponent( + { + setup: () => { + calls.push('innerA created') + onMounted(() => { + calls.push('innerA mounted') + }) + return () => h('div', 'innerA') + } + }, + 10 + ) + + const InnerB = defineAsyncComponent( + { + setup: () => { + calls.push('innerB created') + onMounted(() => { + calls.push('innerB mounted') + }) + return () => h('div', 'innerB') + } + }, + 10 + ) + + const OuterA = defineAsyncComponent( + { + setup: (_, { slots }: any) => { + calls.push('outerA created') + onMounted(() => { + calls.push('outerA mounted') + }) + return () => + h(Fragment, null, [h('div', 'outerA'), slots.default?.()]) + } + }, + 5 + ) + + const OuterB = defineAsyncComponent( + { + setup: (_, { slots }: any) => { + calls.push('outerB created') + onMounted(() => { + calls.push('outerB mounted') + }) + return () => + h(Fragment, null, [h('div', 'outerB'), slots.default?.()]) + } + }, + 5 + ) + + const outerToggle = ref(false) + const innerToggle = ref(false) + + /** + * + * + * + * + * + * + * + */ + const Comp = { + setup() { + return () => + h(Suspense, null, { + default: [ + h(outerToggle.value ? OuterB : OuterA, null, { + default: () => h(Suspense, { suspensible: true },{ + default: h(innerToggle.value ? InnerB : InnerA) + }) + }) + ], + fallback: h('div', 'fallback outer') + }) + } + } + + expected = `
fallback outer
` + const root = nodeOps.createElement('div') + render(h(Comp), root) + expect(serializeInner(root)).toBe(expected) + + // mount outer component + await Promise.all(deps) + await nextTick() + + expect(serializeInner(root)).toBe(expected) + expect(calls).toEqual([`outerA created`]) + + // mount inner component + await Promise.all(deps) + await nextTick() + expected = `
outerA
innerA
` + expect(serializeInner(root)).toBe(expected) + + expect(calls).toEqual([ + 'outerA created', + 'innerA created', + 'outerA mounted', + 'innerA mounted' + ]) + + // toggle outer component + calls.length = 0 + deps.length = 0 + outerToggle.value = true + await nextTick() + + await Promise.all(deps) + await nextTick() + expect(serializeInner(root)).toBe(expected) // expect not change + + await Promise.all(deps) + await nextTick() + expected = `
outerB
innerA
` + expect(serializeInner(root)).toBe(expected) + expect(calls).toContain('outerB mounted') + expect(calls).toContain('innerA mounted') + + // toggle inner component + calls.length = 0 + deps.length = 0 + innerToggle.value = true + await nextTick() + expect(serializeInner(root)).toBe(expected) // expect not change + + await Promise.all(deps) + await nextTick() + expected = `
outerB
innerB
` + expect(serializeInner(root)).toBe(expected) + expect(calls).toContain('innerB mounted') + }) }) diff --git a/packages/runtime-core/src/components/Suspense.ts b/packages/runtime-core/src/components/Suspense.ts index 2862178758f..aef62db4a31 100644 --- a/packages/runtime-core/src/components/Suspense.ts +++ b/packages/runtime-core/src/components/Suspense.ts @@ -35,6 +35,12 @@ export interface SuspenseProps { onPending?: () => void onFallback?: () => void timeout?: string | number + /** + * Allow suspense to be captured by parent suspense + * + * @default false + */ + suspensible?: boolean } export const isSuspense = (type: any): boolean => type.__isSuspense @@ -395,7 +401,7 @@ let hasWarned = false function createSuspenseBoundary( vnode: VNode, - parent: SuspenseBoundary | null, + parentSuspense: SuspenseBoundary | null, parentComponent: ComponentInternalInstance | null, container: RendererElement, hiddenContainer: RendererElement, @@ -423,6 +429,17 @@ function createSuspenseBoundary( o: { parentNode, remove } } = rendererInternals + // if set `suspensible: true`, set the current suspense as a dep of parent suspense + let parentSuspenseId: number | undefined + const isSuspensible = + vnode.props?.suspensible != null && vnode.props.suspensible !== false + if (isSuspensible) { + if (parentSuspense?.pendingBranch) { + parentSuspenseId = parentSuspense?.pendingId + parentSuspense.deps++ + } + } + const timeout = vnode.props ? toNumber(vnode.props.timeout) : undefined if (__DEV__) { assertNumber(timeout, `Suspense timeout`) @@ -430,7 +447,7 @@ function createSuspenseBoundary( const suspense: SuspenseBoundary = { vnode, - parent, + parent: parentSuspense, parentComponent, isSVG, container, @@ -522,6 +539,20 @@ function createSuspenseBoundary( } suspense.effects = [] + // resolve parent suspense if all async deps are resolved + if (isSuspensible) { + if ( + parentSuspense && + parentSuspense.pendingBranch && + parentSuspenseId === parentSuspense.pendingId + ) { + parentSuspense.deps-- + if (parentSuspense.deps === 0) { + parentSuspense.resolve() + } + } + } + // invoke @resolve event triggerEvent(vnode, 'onResolve') },