Skip to content

Commit

Permalink
feat(suspense): introduce suspensible option for <Suspense> (#6736)
Browse files Browse the repository at this point in the history
close #5513
  • Loading branch information
antfu committed Apr 21, 2023
1 parent 15847f3 commit cb37d0b
Show file tree
Hide file tree
Showing 2 changed files with 177 additions and 3 deletions.
145 changes: 144 additions & 1 deletion packages/runtime-core/__tests__/components/Suspense.spec.ts
Expand Up @@ -16,7 +16,8 @@ import {
watchEffect,
onUnmounted,
onErrorCaptured,
shallowRef
shallowRef,
Fragment
} from '@vue/runtime-test'
import { createApp } from 'vue'

Expand Down Expand Up @@ -1257,4 +1258,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
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

0 comments on commit cb37d0b

Please sign in to comment.