@@ -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+
119151describe ( '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
0 commit comments