Skip to content

Commit d17e9d1

Browse files
authored
[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.
1 parent b9af819 commit d17e9d1

File tree

3 files changed

+218
-110
lines changed

3 files changed

+218
-110
lines changed

packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,4 +335,67 @@ describe('ReactDOMFizzStatic', () => {
335335
});
336336
expect(getVisibleChildren(container)).toEqual(undefined);
337337
});
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+
});
338401
});

packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,7 @@ describe('ReactDOMFizzStaticNode', () => {
111111

112112
const result = await resultPromise;
113113
const prelude = await readContent(result.prelude);
114-
expect(prelude).toMatchInlineSnapshot(
115-
`"<div><!--$-->Done<!-- --><!--/$--></div>"`,
116-
);
114+
expect(prelude).toMatchInlineSnapshot(`"<div><!--$-->Done<!--/$--></div>"`);
117115
});
118116

119117
// @gate experimental

packages/react-server/src/ReactFizzServer.js

Lines changed: 154 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1138,125 +1138,172 @@ function renderSuspenseBoundary(
11381138
// no parent segment so there's nothing to wait on.
11391139
contentRootSegment.parentFlushed = true;
11401140

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;
11451146

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;
11531158

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;
11711173
}
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,
11891223
);
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,
11951264
error,
11961265
thrownInfo,
1197-
__DEV__ && enableOwnerStacks ? task.debugTask : null,
1266+
false,
11981267
);
1199-
}
1200-
encodeErrorForBoundary(newBoundary, errorDigest, error, thrownInfo, false);
12011268

1202-
untrackBoundary(request, newBoundary);
1269+
untrackBoundary(request, newBoundary);
12031270

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+
}
12131280

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,
12221286
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);
12351306
}
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);
12601307
}
12611308

12621309
function replaySuspenseBoundary(

0 commit comments

Comments
 (0)