Skip to content

Commit cb37d0b

Browse files
authored
feat(suspense): introduce suspensible option for <Suspense> (#6736)
close #5513
1 parent 15847f3 commit cb37d0b

File tree

2 files changed

+177
-3
lines changed

2 files changed

+177
-3
lines changed

packages/runtime-core/__tests__/components/Suspense.spec.ts

+144-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import {
1616
watchEffect,
1717
onUnmounted,
1818
onErrorCaptured,
19-
shallowRef
19+
shallowRef,
20+
Fragment
2021
} from '@vue/runtime-test'
2122
import { createApp } from 'vue'
2223

@@ -1257,4 +1258,146 @@ describe('Suspense', () => {
12571258
`A component with async setup() must be nested in a <Suspense>`
12581259
).toHaveBeenWarned()
12591260
})
1261+
1262+
test('nested suspense with suspensible', async () => {
1263+
const calls: string[] = []
1264+
let expected = ''
1265+
1266+
const InnerA = defineAsyncComponent(
1267+
{
1268+
setup: () => {
1269+
calls.push('innerA created')
1270+
onMounted(() => {
1271+
calls.push('innerA mounted')
1272+
})
1273+
return () => h('div', 'innerA')
1274+
}
1275+
},
1276+
10
1277+
)
1278+
1279+
const InnerB = defineAsyncComponent(
1280+
{
1281+
setup: () => {
1282+
calls.push('innerB created')
1283+
onMounted(() => {
1284+
calls.push('innerB mounted')
1285+
})
1286+
return () => h('div', 'innerB')
1287+
}
1288+
},
1289+
10
1290+
)
1291+
1292+
const OuterA = defineAsyncComponent(
1293+
{
1294+
setup: (_, { slots }: any) => {
1295+
calls.push('outerA created')
1296+
onMounted(() => {
1297+
calls.push('outerA mounted')
1298+
})
1299+
return () =>
1300+
h(Fragment, null, [h('div', 'outerA'), slots.default?.()])
1301+
}
1302+
},
1303+
5
1304+
)
1305+
1306+
const OuterB = defineAsyncComponent(
1307+
{
1308+
setup: (_, { slots }: any) => {
1309+
calls.push('outerB created')
1310+
onMounted(() => {
1311+
calls.push('outerB mounted')
1312+
})
1313+
return () =>
1314+
h(Fragment, null, [h('div', 'outerB'), slots.default?.()])
1315+
}
1316+
},
1317+
5
1318+
)
1319+
1320+
const outerToggle = ref(false)
1321+
const innerToggle = ref(false)
1322+
1323+
/**
1324+
* <Suspense>
1325+
* <component :is="outerToggle ? outerB : outerA">
1326+
* <Suspense suspensible>
1327+
* <component :is="innerToggle ? innerB : innerA" />
1328+
* </Suspense>
1329+
* </component>
1330+
* </Suspense>
1331+
*/
1332+
const Comp = {
1333+
setup() {
1334+
return () =>
1335+
h(Suspense, null, {
1336+
default: [
1337+
h(outerToggle.value ? OuterB : OuterA, null, {
1338+
default: () => h(Suspense, { suspensible: true },{
1339+
default: h(innerToggle.value ? InnerB : InnerA)
1340+
})
1341+
})
1342+
],
1343+
fallback: h('div', 'fallback outer')
1344+
})
1345+
}
1346+
}
1347+
1348+
expected = `<div>fallback outer</div>`
1349+
const root = nodeOps.createElement('div')
1350+
render(h(Comp), root)
1351+
expect(serializeInner(root)).toBe(expected)
1352+
1353+
// mount outer component
1354+
await Promise.all(deps)
1355+
await nextTick()
1356+
1357+
expect(serializeInner(root)).toBe(expected)
1358+
expect(calls).toEqual([`outerA created`])
1359+
1360+
// mount inner component
1361+
await Promise.all(deps)
1362+
await nextTick()
1363+
expected = `<div>outerA</div><div>innerA</div>`
1364+
expect(serializeInner(root)).toBe(expected)
1365+
1366+
expect(calls).toEqual([
1367+
'outerA created',
1368+
'innerA created',
1369+
'outerA mounted',
1370+
'innerA mounted'
1371+
])
1372+
1373+
// toggle outer component
1374+
calls.length = 0
1375+
deps.length = 0
1376+
outerToggle.value = true
1377+
await nextTick()
1378+
1379+
await Promise.all(deps)
1380+
await nextTick()
1381+
expect(serializeInner(root)).toBe(expected) // expect not change
1382+
1383+
await Promise.all(deps)
1384+
await nextTick()
1385+
expected = `<div>outerB</div><div>innerA</div>`
1386+
expect(serializeInner(root)).toBe(expected)
1387+
expect(calls).toContain('outerB mounted')
1388+
expect(calls).toContain('innerA mounted')
1389+
1390+
// toggle inner component
1391+
calls.length = 0
1392+
deps.length = 0
1393+
innerToggle.value = true
1394+
await nextTick()
1395+
expect(serializeInner(root)).toBe(expected) // expect not change
1396+
1397+
await Promise.all(deps)
1398+
await nextTick()
1399+
expected = `<div>outerB</div><div>innerB</div>`
1400+
expect(serializeInner(root)).toBe(expected)
1401+
expect(calls).toContain('innerB mounted')
1402+
})
12601403
})

packages/runtime-core/src/components/Suspense.ts

+33-2
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ export interface SuspenseProps {
3535
onPending?: () => void
3636
onFallback?: () => void
3737
timeout?: string | number
38+
/**
39+
* Allow suspense to be captured by parent suspense
40+
*
41+
* @default false
42+
*/
43+
suspensible?: boolean
3844
}
3945

4046
export const isSuspense = (type: any): boolean => type.__isSuspense
@@ -395,7 +401,7 @@ let hasWarned = false
395401

396402
function createSuspenseBoundary(
397403
vnode: VNode,
398-
parent: SuspenseBoundary | null,
404+
parentSuspense: SuspenseBoundary | null,
399405
parentComponent: ComponentInternalInstance | null,
400406
container: RendererElement,
401407
hiddenContainer: RendererElement,
@@ -423,14 +429,25 @@ function createSuspenseBoundary(
423429
o: { parentNode, remove }
424430
} = rendererInternals
425431

432+
// if set `suspensible: true`, set the current suspense as a dep of parent suspense
433+
let parentSuspenseId: number | undefined
434+
const isSuspensible =
435+
vnode.props?.suspensible != null && vnode.props.suspensible !== false
436+
if (isSuspensible) {
437+
if (parentSuspense?.pendingBranch) {
438+
parentSuspenseId = parentSuspense?.pendingId
439+
parentSuspense.deps++
440+
}
441+
}
442+
426443
const timeout = vnode.props ? toNumber(vnode.props.timeout) : undefined
427444
if (__DEV__) {
428445
assertNumber(timeout, `Suspense timeout`)
429446
}
430447

431448
const suspense: SuspenseBoundary = {
432449
vnode,
433-
parent,
450+
parent: parentSuspense,
434451
parentComponent,
435452
isSVG,
436453
container,
@@ -522,6 +539,20 @@ function createSuspenseBoundary(
522539
}
523540
suspense.effects = []
524541

542+
// resolve parent suspense if all async deps are resolved
543+
if (isSuspensible) {
544+
if (
545+
parentSuspense &&
546+
parentSuspense.pendingBranch &&
547+
parentSuspenseId === parentSuspense.pendingId
548+
) {
549+
parentSuspense.deps--
550+
if (parentSuspense.deps === 0) {
551+
parentSuspense.resolve()
552+
}
553+
}
554+
}
555+
525556
// invoke @resolve event
526557
triggerEvent(vnode, 'onResolve')
527558
},

0 commit comments

Comments
 (0)