Skip to content

Commit bcda96b

Browse files
authored
fix(suspense): avoid double-patching nested suspense when parent suspense is not resolved (#10055)
close #8678
1 parent 07b19a5 commit bcda96b

File tree

2 files changed

+147
-0
lines changed

2 files changed

+147
-0
lines changed

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

+135
Original file line numberDiff line numberDiff line change
@@ -1641,6 +1641,141 @@ describe('Suspense', () => {
16411641
expect(serializeInner(root)).toBe(expected)
16421642
})
16431643

1644+
//#8678
1645+
test('nested suspense (child suspense update before parent suspense resolve)', async () => {
1646+
const calls: string[] = []
1647+
1648+
const InnerA = defineAsyncComponent(
1649+
{
1650+
setup: () => {
1651+
calls.push('innerA created')
1652+
onMounted(() => {
1653+
calls.push('innerA mounted')
1654+
})
1655+
return () => h('div', 'innerA')
1656+
},
1657+
},
1658+
10,
1659+
)
1660+
1661+
const InnerB = defineAsyncComponent(
1662+
{
1663+
setup: () => {
1664+
calls.push('innerB created')
1665+
onMounted(() => {
1666+
calls.push('innerB mounted')
1667+
})
1668+
return () => h('div', 'innerB')
1669+
},
1670+
},
1671+
10,
1672+
)
1673+
1674+
const OuterA = defineAsyncComponent(
1675+
{
1676+
setup: (_, { slots }: any) => {
1677+
calls.push('outerA created')
1678+
onMounted(() => {
1679+
calls.push('outerA mounted')
1680+
})
1681+
return () =>
1682+
h(Fragment, null, [h('div', 'outerA'), slots.default?.()])
1683+
},
1684+
},
1685+
5,
1686+
)
1687+
1688+
const OuterB = defineAsyncComponent(
1689+
{
1690+
setup: (_, { slots }: any) => {
1691+
calls.push('outerB created')
1692+
onMounted(() => {
1693+
calls.push('outerB mounted')
1694+
})
1695+
return () =>
1696+
h(Fragment, null, [h('div', 'outerB'), slots.default?.()])
1697+
},
1698+
},
1699+
5,
1700+
)
1701+
1702+
const outerToggle = ref(false)
1703+
const innerToggle = ref(false)
1704+
1705+
/**
1706+
* <Suspense>
1707+
* <component :is="outerToggle ? outerB : outerA">
1708+
* <Suspense>
1709+
* <component :is="innerToggle ? innerB : innerA" />
1710+
* </Suspense>
1711+
* </component>
1712+
* </Suspense>
1713+
*/
1714+
const Comp = {
1715+
setup() {
1716+
return () =>
1717+
h(Suspense, null, {
1718+
default: [
1719+
h(outerToggle.value ? OuterB : OuterA, null, {
1720+
default: () =>
1721+
h(Suspense, null, {
1722+
default: h(innerToggle.value ? InnerB : InnerA),
1723+
}),
1724+
}),
1725+
],
1726+
fallback: h('div', 'fallback outer'),
1727+
})
1728+
},
1729+
}
1730+
1731+
const root = nodeOps.createElement('div')
1732+
render(h(Comp), root)
1733+
expect(serializeInner(root)).toBe(`<div>fallback outer</div>`)
1734+
1735+
// mount outer component
1736+
await Promise.all(deps)
1737+
await nextTick()
1738+
1739+
expect(serializeInner(root)).toBe(`<div>outerA</div><!---->`)
1740+
expect(calls).toEqual([`outerA created`, `outerA mounted`])
1741+
1742+
// mount inner component
1743+
await Promise.all(deps)
1744+
await nextTick()
1745+
expect(serializeInner(root)).toBe(`<div>outerA</div><div>innerA</div>`)
1746+
1747+
expect(calls).toEqual([
1748+
'outerA created',
1749+
'outerA mounted',
1750+
'innerA created',
1751+
'innerA mounted',
1752+
])
1753+
1754+
calls.length = 0
1755+
deps.length = 0
1756+
1757+
// toggle both outer and inner components
1758+
outerToggle.value = true
1759+
innerToggle.value = true
1760+
await nextTick()
1761+
1762+
await Promise.all(deps)
1763+
await nextTick()
1764+
expect(serializeInner(root)).toBe(`<div>outerB</div><!---->`)
1765+
1766+
await Promise.all(deps)
1767+
await nextTick()
1768+
expect(serializeInner(root)).toBe(`<div>outerB</div><div>innerB</div>`)
1769+
1770+
// innerB only mount once
1771+
expect(calls).toEqual([
1772+
'outerB created',
1773+
'outerB mounted',
1774+
'innerB created',
1775+
'innerB mounted',
1776+
])
1777+
})
1778+
16441779
// #6416
16451780
test('KeepAlive with Suspense', async () => {
16461781
const Async = defineAsyncComponent({

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

+12
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,18 @@ export const SuspenseImpl = {
9191
rendererInternals,
9292
)
9393
} else {
94+
// #8678 if the current suspense needs to be patched and parentSuspense has
95+
// not been resolved. this means that both the current suspense and parentSuspense
96+
// need to be patched. because parentSuspense's pendingBranch includes the
97+
// current suspense, it will be processed twice:
98+
// 1. current patch
99+
// 2. mounting along with the pendingBranch of parentSuspense
100+
// it is necessary to skip the current patch to avoid multiple mounts
101+
// of inner components.
102+
if (parentSuspense && parentSuspense.deps > 0) {
103+
n2.suspense = n1.suspense
104+
return
105+
}
94106
patchSuspense(
95107
n1,
96108
n2,

0 commit comments

Comments
 (0)