Skip to content
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

feat: introduce suspensible option to <Suspense> to fix suspense flicks #6736

Merged
merged 7 commits into from Apr 21, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
145 changes: 144 additions & 1 deletion packages/runtime-core/__tests__/components/Suspense.spec.ts
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
Expand Up @@ -30,6 +30,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 @@ -388,7 +394,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 @@ -416,10 +422,21 @@ function createSuspenseBoundary(
o: { parentNode, remove }
} = rendererInternals

// if set `suspensible: true`, set the current suspense as a dep of parent suspense
let parentSuspenseId: number | undefined
let isSuspensible =
antfu marked this conversation as resolved.
Show resolved Hide resolved
vnode.props?.suspensible != null && vnode.props.suspensible !== false
if (isSuspensible) {
if (parentSuspense?.pendingBranch) {
parentSuspenseId = parentSuspense?.pendingId
parentSuspense.deps++
}
}

const timeout = toNumber(vnode.props && vnode.props.timeout)
const suspense: SuspenseBoundary = {
vnode,
parent,
parent: parentSuspense,
parentComponent,
isSVG,
container,
Expand Down Expand Up @@ -511,6 +528,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