@samitouri / QOS-React / commits / d17e9d1ce5

[Fizz] Prerender fallbacks before children (#30483)

When prerendering it can be convenient to abort the prerender while rendering. However if any Suspense fallbacks have not yet rendered before the abort happens the fallback itself will error and cause the nearest parent Suspense boundary to render a fallback instead. Prerenders are by definition not time critical so the prioritization of children over fallbacks which makes sense for render isn't similarly motivated for prerender. Given this, this change updates fallback rendering during a prerender to attempt the fallback before attempting children.

Josh Story committed Jul 26, 2024 at 14:06 UTC d17e9d1ce566276fc54a8ea27f4e9ea1fa434e62
3 files changed +218 -110
packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js
+63
@@ -335,4 +335,67 @@ describe('ReactDOMFizzStatic', () => {
335 });
336 expect(getVisibleChildren(container)).toEqual(undefined);
337 });
338 +
339 + // @gate experimental
340 + it('will prerender Suspense fallbacks before children', async () => {
341 + const values = [];
342 + function Indirection({children}) {
343 + values.push(children);
344 + return children;
345 + }
346 +
347 + function App() {
348 + return (
349 + <div>
350 + <Suspense
351 + fallback={
352 + <div>
353 + <Indirection>outer loading...</Indirection>
354 + </div>
355 + }>
356 + <Suspense
357 + fallback={
358 + <div>
359 + <Indirection>first inner loading...</Indirection>
360 + </div>
361 + }>
362 + <div>
363 + <Indirection>hello world</Indirection>
364 + </div>
365 + </Suspense>
366 + <Suspense
367 + fallback={
368 + <div>
369 + <Indirection>second inner loading...</Indirection>
370 + </div>
371 + }>
372 + <div>
373 + <Indirection>goodbye world</Indirection>
374 + </div>
375 + </Suspense>
376 + </Suspense>
377 + </div>
378 + );
379 + }
380 +
381 + const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />);
382 +
383 + expect(values).toEqual([
384 + 'outer loading...',
385 + 'first inner loading...',
386 + 'second inner loading...',
387 + 'hello world',
388 + 'goodbye world',
389 + ]);
390 +
391 + await act(async () => {
392 + result.prelude.pipe(writable);
393 + });
394 + expect(getVisibleChildren(container)).toEqual(
395 + <div>
396 + <div>hello world</div>
397 + <div>goodbye world</div>
398 + </div>,
399 + );
400 + });
401 });
packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js
+1 -3
@@ -111,9 +111,7 @@ describe('ReactDOMFizzStaticNode', () => {
111
112 const result = await resultPromise;
113 const prelude = await readContent(result.prelude);
114 - expect(prelude).toMatchInlineSnapshot(
115 - `"<div><!--$-->Done<!-- --><!--/$--></div>"`,
116 - );
114 + expect(prelude).toMatchInlineSnapshot(`"<div><!--$-->Done<!--/$--></div>"`);
115 });
116
117 // @gate experimental
packages/react-server/src/ReactFizzServer.js
+154 -107
@@ -1138,125 +1138,172 @@ function renderSuspenseBoundary(
1138 // no parent segment so there's nothing to wait on.
1139 contentRootSegment.parentFlushed = true;
1140
1141 - // Currently this is running synchronously. We could instead schedule this to pingedTasks.
1142 - // I suspect that there might be some efficiency benefits from not creating the suspended task
1143 - // and instead just using the stack if possible.
1144 - // TODO: Call this directly instead of messing with saving and restoring contexts.
1141 + if (request.trackedPostpones !== null) {
1142 + // This is a prerender. In this mode we want to render the fallback synchronously and schedule
1143 + // the content to render later. This is the opposite of what we do during a normal render
1144 + // where we try to skip rendering the fallback if the content itself can render synchronously
1145 + const trackedPostpones = request.trackedPostpones;
1146
1146 - // We can reuse the current context and task to render the content immediately without
1147 - // context switching. We just need to temporarily switch which boundary and which segment
1148 - // we're writing to. If something suspends, it'll spawn new suspended task with that context.
1149 - task.blockedBoundary = newBoundary;
1150 - task.hoistableState = newBoundary.contentState;
1151 - task.blockedSegment = contentRootSegment;
1152 - task.keyPath = keyPath;
1147 + const fallbackKeyPath = [keyPath[0], 'Suspense Fallback', keyPath[2]];
1148 + const fallbackReplayNode: ReplayNode = [
1149 + fallbackKeyPath[1],
1150 + fallbackKeyPath[2],
1151 + ([]: Array<ReplayNode>),
1152 + null,
1153 + ];
1154 + trackedPostpones.workingMap.set(fallbackKeyPath, fallbackReplayNode);
1155 + // We are rendering the fallback before the boundary content so we keep track of
1156 + // the fallback replay node until we determine if the primary content suspends
1157 + newBoundary.trackedFallbackNode = fallbackReplayNode;
1158
1154 - try {
1155 - // We use the safe form because we don't handle suspending here. Only error handling.
1156 - renderNode(request, task, content, -1);
1157 - pushSegmentFinale(
1158 - contentRootSegment.chunks,
1159 - request.renderState,
1160 - contentRootSegment.lastPushedText,
1161 - contentRootSegment.textEmbedded,
1162 - );
1163 - contentRootSegment.status = COMPLETED;
1164 - queueCompletedSegment(newBoundary, contentRootSegment);
1165 - if (newBoundary.pendingTasks === 0 && newBoundary.status === PENDING) {
1166 - // This must have been the last segment we were waiting on. This boundary is now complete.
1167 - // Therefore we won't need the fallback. We early return so that we don't have to create
1168 - // the fallback.
1169 - newBoundary.status = COMPLETED;
1170 - return;
1159 + task.blockedSegment = boundarySegment;
1160 + task.keyPath = fallbackKeyPath;
1161 + try {
1162 + renderNode(request, task, fallback, -1);
1163 + pushSegmentFinale(
1164 + boundarySegment.chunks,
1165 + request.renderState,
1166 + boundarySegment.lastPushedText,
1167 + boundarySegment.textEmbedded,
1168 + );
1169 + boundarySegment.status = COMPLETED;
1170 + } finally {
1171 + task.blockedSegment = parentSegment;
1172 + task.keyPath = prevKeyPath;
1173 }
1172 - } catch (error: mixed) {
1173 - contentRootSegment.status = ERRORED;
1174 - newBoundary.status = CLIENT_RENDERED;
1175 - const thrownInfo = getThrownInfo(task.componentStack);
1176 - let errorDigest;
1177 - if (
1178 - enablePostpone &&
1179 - typeof error === 'object' &&
1180 - error !== null &&
1181 - error.$$typeof === REACT_POSTPONE_TYPE
1182 - ) {
1183 - const postponeInstance: Postpone = (error: any);
1184 - logPostpone(
1185 - request,
1186 - postponeInstance.message,
1187 - thrownInfo,
1188 - __DEV__ && enableOwnerStacks ? task.debugTask : null,
1174 +
1175 + // We create a suspended task for the primary content because we want to allow
1176 + // sibling fallbacks to be rendered first.
1177 + const suspendedPrimaryTask = createRenderTask(
1178 + request,
1179 + null,
1180 + content,
1181 + -1,
1182 + newBoundary,
1183 + contentRootSegment,
1184 + newBoundary.contentState,
1185 + task.abortSet,
1186 + keyPath,
1187 + task.formatContext,
1188 + task.context,
1189 + task.treeContext,
1190 + task.componentStack,
1191 + task.isFallback,
1192 + !disableLegacyContext ? task.legacyContext : emptyContextObject,
1193 + __DEV__ && enableOwnerStacks ? task.debugTask : null,
1194 + );
1195 + pushComponentStack(suspendedPrimaryTask);
1196 + request.pingedTasks.push(suspendedPrimaryTask);
1197 + } else {
1198 + // This is a normal render. We will attempt to synchronously render the boundary content
1199 + // If it is successful we will elide the fallback task but if it suspends or errors we schedule
1200 + // the fallback to render. Unlike with prerenders we attempt to deprioritize the fallback render
1201 +
1202 + // Currently this is running synchronously. We could instead schedule this to pingedTasks.
1203 + // I suspect that there might be some efficiency benefits from not creating the suspended task
1204 + // and instead just using the stack if possible.
1205 + // TODO: Call this directly instead of messing with saving and restoring contexts.
1206 +
1207 + // We can reuse the current context and task to render the content immediately without
1208 + // context switching. We just need to temporarily switch which boundary and which segment
1209 + // we're writing to. If something suspends, it'll spawn new suspended task with that context.
1210 + task.blockedBoundary = newBoundary;
1211 + task.hoistableState = newBoundary.contentState;
1212 + task.blockedSegment = contentRootSegment;
1213 + task.keyPath = keyPath;
1214 +
1215 + try {
1216 + // We use the safe form because we don't handle suspending here. Only error handling.
1217 + renderNode(request, task, content, -1);
1218 + pushSegmentFinale(
1219 + contentRootSegment.chunks,
1220 + request.renderState,
1221 + contentRootSegment.lastPushedText,
1222 + contentRootSegment.textEmbedded,
1223 );
1190 - // TODO: Figure out a better signal than a magic digest value.
1191 - errorDigest = 'POSTPONE';
1192 - } else {
1193 - errorDigest = logRecoverableError(
1194 - request,
1224 + contentRootSegment.status = COMPLETED;
1225 + queueCompletedSegment(newBoundary, contentRootSegment);
1226 + if (newBoundary.pendingTasks === 0 && newBoundary.status === PENDING) {
1227 + // This must have been the last segment we were waiting on. This boundary is now complete.
1228 + // Therefore we won't need the fallback. We early return so that we don't have to create
1229 + // the fallback.
1230 + newBoundary.status = COMPLETED;
1231 + return;
1232 + }
1233 + } catch (error: mixed) {
1234 + contentRootSegment.status = ERRORED;
1235 + newBoundary.status = CLIENT_RENDERED;
1236 + const thrownInfo = getThrownInfo(task.componentStack);
1237 + let errorDigest;
1238 + if (
1239 + enablePostpone &&
1240 + typeof error === 'object' &&
1241 + error !== null &&
1242 + error.$$typeof === REACT_POSTPONE_TYPE
1243 + ) {
1244 + const postponeInstance: Postpone = (error: any);
1245 + logPostpone(
1246 + request,
1247 + postponeInstance.message,
1248 + thrownInfo,
1249 + __DEV__ && enableOwnerStacks ? task.debugTask : null,
1250 + );
1251 + // TODO: Figure out a better signal than a magic digest value.
1252 + errorDigest = 'POSTPONE';
1253 + } else {
1254 + errorDigest = logRecoverableError(
1255 + request,
1256 + error,
1257 + thrownInfo,
1258 + __DEV__ && enableOwnerStacks ? task.debugTask : null,
1259 + );
1260 + }
1261 + encodeErrorForBoundary(
1262 + newBoundary,
1263 + errorDigest,
1264 error,
1265 thrownInfo,
1197 - __DEV__ && enableOwnerStacks ? task.debugTask : null,
1266 + false,
1267 );
1199 - }
1200 - encodeErrorForBoundary(newBoundary, errorDigest, error, thrownInfo, false);
1268
1202 - untrackBoundary(request, newBoundary);
1269 + untrackBoundary(request, newBoundary);
1270
1204 - // We don't need to decrement any task numbers because we didn't spawn any new task.
1205 - // We don't need to schedule any task because we know the parent has written yet.
1206 - // We do need to fallthrough to create the fallback though.
1207 - } finally {
1208 - task.blockedBoundary = parentBoundary;
1209 - task.hoistableState = parentHoistableState;
1210 - task.blockedSegment = parentSegment;
1211 - task.keyPath = prevKeyPath;
1212 - }
1271 + // We don't need to decrement any task numbers because we didn't spawn any new task.
1272 + // We don't need to schedule any task because we know the parent has written yet.
1273 + // We do need to fallthrough to create the fallback though.
1274 + } finally {
1275 + task.blockedBoundary = parentBoundary;
1276 + task.hoistableState = parentHoistableState;
1277 + task.blockedSegment = parentSegment;
1278 + task.keyPath = prevKeyPath;
1279 + }
1280
1214 - const fallbackKeyPath = [keyPath[0], 'Suspense Fallback', keyPath[2]];
1215 - const trackedPostpones = request.trackedPostpones;
1216 - if (trackedPostpones !== null) {
1217 - // We create a detached replay node to track any postpones inside the fallback.
1218 - const fallbackReplayNode: ReplayNode = [
1219 - fallbackKeyPath[1],
1220 - fallbackKeyPath[2],
1221 - ([]: Array<ReplayNode>),
1281 + const fallbackKeyPath = [keyPath[0], 'Suspense Fallback', keyPath[2]];
1282 + // We create suspended task for the fallback because we don't want to actually work
1283 + // on it yet in case we finish the main content, so we queue for later.
1284 + const suspendedFallbackTask = createRenderTask(
1285 + request,
1286 null,
1223 - ];
1224 - trackedPostpones.workingMap.set(fallbackKeyPath, fallbackReplayNode);
1225 - if (newBoundary.status === POSTPONED) {
1226 - // This must exist now.
1227 - const boundaryReplayNode: ReplaySuspenseBoundary =
1228 - (trackedPostpones.workingMap.get(keyPath): any);
1229 - boundaryReplayNode[4] = fallbackReplayNode;
1230 - } else {
1231 - // We might not inject it into the postponed tree, unless the content actually
1232 - // postpones too. We need to keep track of it until that happpens.
1233 - newBoundary.trackedFallbackNode = fallbackReplayNode;
1234 - }
1287 + fallback,
1288 + -1,
1289 + parentBoundary,
1290 + boundarySegment,
1291 + newBoundary.fallbackState,
1292 + fallbackAbortSet,
1293 + fallbackKeyPath,
1294 + task.formatContext,
1295 + task.context,
1296 + task.treeContext,
1297 + task.componentStack,
1298 + true,
1299 + !disableLegacyContext ? task.legacyContext : emptyContextObject,
1300 + __DEV__ && enableOwnerStacks ? task.debugTask : null,
1301 + );
1302 + pushComponentStack(suspendedFallbackTask);
1303 + // TODO: This should be queued at a separate lower priority queue so that we only work
1304 + // on preparing fallbacks if we don't have any more main content to task on.
1305 + request.pingedTasks.push(suspendedFallbackTask);
1306 }
1236 - // We create suspended task for the fallback because we don't want to actually work
1237 - // on it yet in case we finish the main content, so we queue for later.
1238 - const suspendedFallbackTask = createRenderTask(
1239 - request,
1240 - null,
1241 - fallback,
1242 - -1,
1243 - parentBoundary,
1244 - boundarySegment,
1245 - newBoundary.fallbackState,
1246 - fallbackAbortSet,
1247 - fallbackKeyPath,
1248 - task.formatContext,
1249 - task.context,
1250 - task.treeContext,
1251 - task.componentStack,
1252 - true,
1253 - !disableLegacyContext ? task.legacyContext : emptyContextObject,
1254 - __DEV__ && enableOwnerStacks ? task.debugTask : null,
1255 - );
1256 - pushComponentStack(suspendedFallbackTask);
1257 - // TODO: This should be queued at a separate lower priority queue so that we only work
1258 - // on preparing fallbacks if we don't have any more main content to task on.
1259 - request.pingedTasks.push(suspendedFallbackTask);
1307 }
1308
1309 function replaySuspenseBoundary(