Skip to content
Merged
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
145 changes: 144 additions & 1 deletion packages/runtime-core/__tests__/components/Suspense.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import {
watchEffect,
onUnmounted,
onErrorCaptured,
shallowRef
shallowRef,
Fragment
} from '@vue/runtime-test'
import { createApp } from 'vue'

Expand Down Expand Up @@ -1253,4 +1254,146 @@ describe('Suspense', () => {
`A component with async setup() must be nested in a <Suspense>`
).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)

/**
* <Suspense>
* <component :is="outerToggle ? outerB : outerA">
* <Suspense suspensible>
* <component :is="innerToggle ? innerB : innerA" />
* </Suspense>
* </component>
* </Suspense>
*/
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 = `<div>fallback outer</div>`
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 = `<div>outerA</div><div>innerA</div>`
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 = `<div>outerB</div><div>innerA</div>`
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 = `<div>outerB</div><div>innerB</div>`
expect(serializeInner(root)).toBe(expected)
expect(calls).toContain('innerB mounted')
})
})
35 changes: 33 additions & 2 deletions packages/runtime-core/src/components/Suspense.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -395,7 +401,7 @@ let hasWarned = false

function createSuspenseBoundary(
vnode: VNode,
parent: SuspenseBoundary | null,
parentSuspense: SuspenseBoundary | null,
parentComponent: ComponentInternalInstance | null,
container: RendererElement,
hiddenContainer: RendererElement,
Expand Down Expand Up @@ -423,14 +429,25 @@ 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`)
}

const suspense: SuspenseBoundary = {
vnode,
parent,
parent: parentSuspense,
parentComponent,
isSVG,
container,
Expand Down Expand Up @@ -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')
},
Expand Down