Skip to content

Commit 76d42dc

Browse files
authored
fix(suspense): don't drop nested suspense patches during hydration (#15429)
1 parent bfcfe9e commit 76d42dc

2 files changed

Lines changed: 321 additions & 1 deletion

File tree

‎packages/runtime-core/__tests__/hydration.spec.ts‎

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
Teleport,
1111
Transition,
1212
type VNode,
13+
type VNodeChild,
1314
createBlock,
1415
createCommentVNode,
1516
createElementBlock,
@@ -116,6 +117,37 @@ async function hydrateSuspenseApp<S extends object>(
116117
}
117118
}
118119

120+
/**
121+
* The shape that keeps a root <Suspense> pending for a whole hydration: a
122+
* nested suspensible boundary inside it holding the content the test toggles.
123+
* Only the inner view tracks that state, so an update patches the nested
124+
* boundary directly - the shape a router-driven <RouterView> takes.
125+
*/
126+
function nestedSuspenseApp(opts: {
127+
content: () => VNodeChild
128+
onRootResolve?: () => void
129+
onNestedResolve?: () => void
130+
// props for a <Transition> wrapped around the nested boundary
131+
transition?: Record<string, unknown>
132+
}): Component {
133+
const View = defineComponent({
134+
setup() {
135+
const inner = () =>
136+
h(
137+
Suspense,
138+
{ suspensible: true, onResolve: opts.onNestedResolve },
139+
{ default: opts.content },
140+
)
141+
return () =>
142+
opts.transition ? h(Transition, opts.transition, inner) : inner()
143+
},
144+
})
145+
return defineComponent({
146+
setup: () => () =>
147+
h(Suspense, { onResolve: opts.onRootResolve }, () => h(View)),
148+
})
149+
}
150+
119151
describe('SSR hydration', () => {
120152
beforeEach(() => {
121153
document.body.innerHTML = ''
@@ -1090,6 +1122,290 @@ describe('SSR hydration', () => {
10901122
expect(container.innerHTML).toBe(`<span>1</span>`)
10911123
})
10921124

1125+
// updating the content of a nested suspensible suspense while the root
1126+
// suspense is still hydrating must be patched through: the root suspense
1127+
// keeps pending deps for the whole hydration, so the skip introduced for
1128+
// #8678 would drop the update and leave the stale SSR DOM on screen
1129+
// forever (e.g. a router navigation before hydration finishes)
1130+
test('Suspense: update nested suspensible suspense during hydration', async () => {
1131+
// hand-rolled rather than nestedSuspenseApp(): `route` is read in the root
1132+
// App's own render, so the update reaches the nested boundary through the
1133+
// root's same-root patch instead of patching it directly
1134+
const {
1135+
container,
1136+
ssrHtml,
1137+
route,
1138+
release,
1139+
onRootResolve,
1140+
onNestedResolve,
1141+
} = await hydrateSuspenseApp(gate => {
1142+
const route = ref('a')
1143+
const onRootResolve = vi.fn()
1144+
const onNestedResolve = vi.fn()
1145+
1146+
const AsyncChild = defineComponent({
1147+
async setup() {
1148+
await gate()
1149+
return () => h('div', 'async child')
1150+
},
1151+
})
1152+
const PageA = defineComponent({
1153+
setup: () => () => h('div', [h('span', 'page a'), h(AsyncChild)]),
1154+
})
1155+
const PageB = defineComponent({
1156+
setup: () => () => h('div', [h('span', 'page b')]),
1157+
})
1158+
1159+
const App = defineComponent({
1160+
setup() {
1161+
return () =>
1162+
h(
1163+
Suspense,
1164+
{ onResolve: onRootResolve },
1165+
{
1166+
default: () =>
1167+
h(
1168+
Suspense,
1169+
{ suspensible: true, onResolve: onNestedResolve },
1170+
{
1171+
default: () =>
1172+
route.value === 'a'
1173+
? h(PageA, { key: 'a' })
1174+
: h(PageB, { key: 'b' }),
1175+
},
1176+
),
1177+
},
1178+
)
1179+
},
1180+
})
1181+
1182+
return { App, route, onRootResolve, onNestedResolve }
1183+
})
1184+
1185+
expect(ssrHtml).toBe(`<div><span>page a</span><div>async child</div></div>`)
1186+
expect(onRootResolve).not.toHaveBeenCalled()
1187+
expect(container.innerHTML).toBe(
1188+
`<div><span>page a</span><div>async child</div></div>`,
1189+
)
1190+
1191+
// toggle the nested suspense content while hydration is still pending:
1192+
// the stale SSR branch is unmounted and the new content shows immediately
1193+
route.value = 'b'
1194+
await nextTick()
1195+
expect(container.innerHTML).toBe(`<div><span>page b</span></div>`)
1196+
expect(onNestedResolve).toHaveBeenCalledTimes(1)
1197+
expect(onRootResolve).toHaveBeenCalledTimes(1)
1198+
1199+
// the late-resolving abandoned branch must not resurrect the old content
1200+
release()
1201+
await new Promise(r => setTimeout(r))
1202+
expect(container.innerHTML).toBe(`<div><span>page b</span></div>`)
1203+
expect(onNestedResolve).toHaveBeenCalledTimes(1)
1204+
expect(onRootResolve).toHaveBeenCalledTimes(1)
1205+
})
1206+
1207+
test('Suspense: updating resolved nested suspense does not resolve hydrating parent', async () => {
1208+
const { container, route, release, onRootResolve } =
1209+
await hydrateSuspenseApp(gate => {
1210+
const route = ref('a')
1211+
const onRootResolve = vi.fn()
1212+
1213+
const AsyncSibling = defineComponent({
1214+
async setup() {
1215+
await gate()
1216+
return () => h('span', 'async sibling')
1217+
},
1218+
})
1219+
const PageA = defineComponent({
1220+
setup: () => () => h('p', 'page a'),
1221+
})
1222+
const PageB = defineComponent({
1223+
setup: () => () => h('p', 'page b'),
1224+
})
1225+
const RouteView = defineComponent({
1226+
setup: () => () =>
1227+
h(
1228+
Suspense,
1229+
{ suspensible: true },
1230+
{
1231+
default: () => (route.value === 'a' ? h(PageA) : h(PageB)),
1232+
},
1233+
),
1234+
})
1235+
const App = defineComponent({
1236+
setup: () => () =>
1237+
h(
1238+
Suspense,
1239+
{ onResolve: onRootResolve },
1240+
{ default: () => h('div', [h(AsyncSibling), h(RouteView)]) },
1241+
),
1242+
})
1243+
1244+
return { App, route, onRootResolve }
1245+
})
1246+
1247+
expect(onRootResolve).not.toHaveBeenCalled()
1248+
1249+
route.value = 'b'
1250+
await nextTick()
1251+
expect(container.innerHTML).toBe(
1252+
`<div><span>async sibling</span><p>page b</p></div>`,
1253+
)
1254+
expect(onRootResolve).not.toHaveBeenCalled()
1255+
1256+
release()
1257+
await new Promise(r => setTimeout(r))
1258+
expect(onRootResolve).toHaveBeenCalledTimes(1)
1259+
})
1260+
1261+
// a nested suspense that was already toggled once during hydration has
1262+
// isHydrating unset, but while the root suspense is still hydrating a
1263+
// second toggle must also be patched through (checked via
1264+
// parentSuspense.isHydrating): the first toggle may leave the boundary
1265+
// pending on an async branch, and dropping the second toggle would strand
1266+
// the view on the stale SSR DOM until that abandoned branch resolves
1267+
test('Suspense: update nested suspensible suspense twice during hydration', async () => {
1268+
const { container, ssrHtml, route, release, onRootResolve, onChildSetup } =
1269+
await hydrateSuspenseApp(gate => {
1270+
const route = ref('a')
1271+
const onRootResolve = vi.fn()
1272+
const onChildSetup = vi.fn()
1273+
1274+
const asyncChild = (name: string, text: string) =>
1275+
defineComponent({
1276+
async setup() {
1277+
onChildSetup(text)
1278+
await gate(name)
1279+
return () => h('div', text)
1280+
},
1281+
})
1282+
const HydrationChild = asyncChild('hydration', 'hydration child')
1283+
const TargetChild = asyncChild('target', 'target child')
1284+
1285+
const PageA = defineComponent({
1286+
setup: () => () => h('div', [h('span', 'page a'), h(HydrationChild)]),
1287+
})
1288+
const PageB = defineComponent({
1289+
setup: () => () => h('div', [h('span', 'page b'), h(TargetChild)]),
1290+
})
1291+
const PageC = defineComponent({
1292+
setup: () => () => h('div', [h('span', 'page c')]),
1293+
})
1294+
1295+
const App = nestedSuspenseApp({
1296+
onRootResolve,
1297+
content: () =>
1298+
route.value === 'a'
1299+
? h(PageA, { key: 'a' })
1300+
: route.value === 'b'
1301+
? h(PageB, { key: 'b' })
1302+
: h(PageC, { key: 'c' }),
1303+
})
1304+
1305+
return { App, route, onRootResolve, onChildSetup }
1306+
})
1307+
1308+
expect(ssrHtml).toBe(
1309+
`<div><span>page a</span><div>hydration child</div></div>`,
1310+
)
1311+
expect(onRootResolve).not.toHaveBeenCalled()
1312+
1313+
// first toggle while hydrating: the target is mounted but pends on its
1314+
// async child, so the SSR DOM of page a stays visible and the boundary is
1315+
// no longer hydrating
1316+
route.value = 'b'
1317+
await nextTick()
1318+
expect(onChildSetup).toHaveBeenCalledWith('target child')
1319+
expect(container.innerHTML).toBe(
1320+
`<div><span>page a</span><div>hydration child</div></div>`,
1321+
)
1322+
expect(onRootResolve).not.toHaveBeenCalled()
1323+
1324+
// second toggle while the root suspense is still hydrating
1325+
route.value = 'c'
1326+
await nextTick()
1327+
expect(container.innerHTML).toBe(`<div><span>page c</span></div>`)
1328+
expect(onRootResolve).toHaveBeenCalledTimes(1)
1329+
1330+
// neither abandoned branch may resurrect once its gate resolves
1331+
release('target')
1332+
release('hydration')
1333+
await new Promise(r => setTimeout(r))
1334+
expect(container.innerHTML).toBe(`<div><span>page c</span></div>`)
1335+
expect(onRootResolve).toHaveBeenCalledTimes(1)
1336+
})
1337+
1338+
// the common case: navigate during hydration to a page that is itself
1339+
// async, then let it load. the SSR DOM stays until the target's async setup
1340+
// resolves, then the target replaces it and the root resolves exactly once
1341+
test('Suspense: update nested suspensible suspense during hydration to an async target', async () => {
1342+
const { container, ssrHtml, route, release, onRootResolve } =
1343+
await hydrateSuspenseApp(gate => {
1344+
const route = ref('a')
1345+
const onRootResolve = vi.fn()
1346+
1347+
const HydrationChild = defineComponent({
1348+
async setup() {
1349+
await gate('hydration')
1350+
return () => h('div', 'hydration child')
1351+
},
1352+
})
1353+
const TargetChild = defineComponent({
1354+
async setup() {
1355+
await gate('target')
1356+
return () => h('div', 'target child')
1357+
},
1358+
})
1359+
1360+
const PageA = defineComponent({
1361+
setup: () => () => h('div', [h('span', 'page a'), h(HydrationChild)]),
1362+
})
1363+
const PageB = defineComponent({
1364+
setup: () => () => h('div', [h('span', 'page b'), h(TargetChild)]),
1365+
})
1366+
1367+
const App = nestedSuspenseApp({
1368+
onRootResolve,
1369+
content: () =>
1370+
route.value === 'a'
1371+
? h(PageA, { key: 'a' })
1372+
: h(PageB, { key: 'b' }),
1373+
})
1374+
1375+
return { App, route, onRootResolve }
1376+
})
1377+
1378+
expect(ssrHtml).toBe(
1379+
`<div><span>page a</span><div>hydration child</div></div>`,
1380+
)
1381+
expect(onRootResolve).not.toHaveBeenCalled()
1382+
1383+
// the target pends on its own async child: the SSR DOM stays visible
1384+
route.value = 'b'
1385+
await nextTick()
1386+
expect(container.innerHTML).toBe(
1387+
`<div><span>page a</span><div>hydration child</div></div>`,
1388+
)
1389+
expect(onRootResolve).not.toHaveBeenCalled()
1390+
1391+
// the target finishes loading: it replaces the SSR DOM and hydration
1392+
// completes without waiting for the abandoned branch
1393+
release('target')
1394+
await new Promise(r => setTimeout(r))
1395+
expect(container.innerHTML).toBe(
1396+
`<div><span>page b</span><div>target child</div></div>`,
1397+
)
1398+
expect(onRootResolve).toHaveBeenCalledTimes(1)
1399+
1400+
// the abandoned branch resolving must not resurrect the old content
1401+
release('hydration')
1402+
await new Promise(r => setTimeout(r))
1403+
expect(container.innerHTML).toBe(
1404+
`<div><span>page b</span><div>target child</div></div>`,
1405+
)
1406+
expect(onRootResolve).toHaveBeenCalledTimes(1)
1407+
})
1408+
10931409
// a same-root-type update to a hydrating suspense used to patch against the
10941410
// detached hiddenContainer while the anchors come from the live SSR DOM, so
10951411
// inserting a node threw. the pending branch of a hydrating boundary lives

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,13 @@ export const SuspenseImpl = {
108108
// 2. mounting along with the pendingBranch of parentSuspense
109109
// it is necessary to skip the current patch to avoid multiple mounts
110110
// of inner components.
111+
// but not while the parent is hydrating: its pending branch is the
112+
// adopted SSR DOM, never mounted again, so skipping leaves it stale.
111113
if (
112114
parentSuspense &&
113115
parentSuspense.deps > 0 &&
114-
!n1.suspense!.isInFallback
116+
!n1.suspense!.isInFallback &&
117+
!parentSuspense.isHydrating
115118
) {
116119
n2.suspense = n1.suspense!
117120
n2.suspense.vnode = n2
@@ -651,6 +654,7 @@ function createSuspenseBoundary(
651654
parentSuspense.pendingBranch &&
652655
parentSuspenseId === parentSuspense.pendingId
653656
) {
657+
parentSuspenseId = undefined
654658
parentSuspense.deps--
655659
if (parentSuspense.deps === 0 && !sync) {
656660
parentSuspense.resolve()

0 commit comments

Comments
 (0)