Skip to content

Commit 05fbd1a

Browse files
authored
[Fizz] Postponing in the shell (#27569)
When we postpone a prerender in the shell, we should just leave an empty prelude and resume from the root. While preserving any options passed in. Since we haven't flushed anything we can't assume we've already emitted html/body tags or any resources tracked in the resumable state. This introduces a resetResumableState function to reset anything we didn't flush. This is a bit hacky. Ideally, we probably shouldn't have tracked it as already happened until it flushed or something like that. Basically, it's like restarting the prerender with the same options and then immediately aborting. When we add the preload headers, we'd track those as preload() being emitted after the reset and so they get readded to the resumable state in that case.
1 parent 779d593 commit 05fbd1a

File tree

6 files changed

+214
-4
lines changed

6 files changed

+214
-4
lines changed

packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,29 @@ export function createResumableState(
576576
};
577577
}
578578

579+
export function resetResumableState(
580+
resumableState: ResumableState,
581+
renderState: RenderState,
582+
): void {
583+
// Resets the resumable state based on what didn't manage to fully flush in the render state.
584+
// This currently assumes nothing was flushed.
585+
resumableState.nextFormID = 0;
586+
resumableState.hasBody = false;
587+
resumableState.hasHtml = false;
588+
resumableState.unknownResources = {};
589+
resumableState.dnsResources = {};
590+
resumableState.connectResources = {
591+
default: {},
592+
anonymous: {},
593+
credentials: {},
594+
};
595+
resumableState.imageResources = {};
596+
resumableState.styleResources = {};
597+
resumableState.scriptResources = {};
598+
resumableState.moduleUnknownResources = {};
599+
resumableState.moduleScriptResources = {};
600+
}
601+
579602
// Constants for the insertion mode we're currently writing in. We don't encode all HTML5 insertion
580603
// modes. We only include the variants as they matter for the sake of our purposes.
581604
// We don't actually provide the namespace therefore we use constants instead of the string.

packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ export {
158158
hoistResources,
159159
setCurrentlyRenderingBoundaryResourcesTarget,
160160
prepareHostDispatcher,
161+
resetResumableState,
161162
} from './ReactFizzConfigDOM';
162163

163164
import escapeTextForBrowser from './escapeTextForBrowser';

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

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1187,4 +1187,133 @@ describe('ReactDOMFizzStaticBrowser', () => {
11871187

11881188
expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
11891189
});
1190+
1191+
// @gate enablePostpone
1192+
it('emits an empty prelude and resumes at the root if we postpone in the shell', async () => {
1193+
let prerendering = true;
1194+
function Postpone() {
1195+
if (prerendering) {
1196+
React.unstable_postpone();
1197+
}
1198+
return 'Hello';
1199+
}
1200+
1201+
function App() {
1202+
return (
1203+
<html lang="en">
1204+
<body>
1205+
<link rel="stylesheet" href="my-style" precedence="high" />
1206+
<Postpone />
1207+
</body>
1208+
</html>
1209+
);
1210+
}
1211+
1212+
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
1213+
expect(prerendered.postponed).not.toBe(null);
1214+
1215+
prerendering = false;
1216+
1217+
expect(await readContent(prerendered.prelude)).toBe('');
1218+
1219+
const content = await ReactDOMFizzServer.resume(
1220+
<App />,
1221+
JSON.parse(JSON.stringify(prerendered.postponed)),
1222+
);
1223+
1224+
expect(await readContent(content)).toBe(
1225+
'<!DOCTYPE html><html lang="en"><head>' +
1226+
'<link rel="stylesheet" href="my-style" data-precedence="high"/>' +
1227+
'</head><body>Hello</body></html>',
1228+
);
1229+
});
1230+
1231+
// @gate enablePostpone
1232+
it('emits an empty prelude if we have not rendered html or head tags yet', async () => {
1233+
let prerendering = true;
1234+
function Postpone() {
1235+
if (prerendering) {
1236+
React.unstable_postpone();
1237+
}
1238+
return (
1239+
<html lang="en">
1240+
<body>Hello</body>
1241+
</html>
1242+
);
1243+
}
1244+
1245+
function App() {
1246+
return (
1247+
<>
1248+
<link rel="stylesheet" href="my-style" precedence="high" />
1249+
<Postpone />
1250+
</>
1251+
);
1252+
}
1253+
1254+
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
1255+
expect(prerendered.postponed).not.toBe(null);
1256+
1257+
prerendering = false;
1258+
1259+
expect(await readContent(prerendered.prelude)).toBe('');
1260+
1261+
const content = await ReactDOMFizzServer.resume(
1262+
<App />,
1263+
JSON.parse(JSON.stringify(prerendered.postponed)),
1264+
);
1265+
1266+
expect(await readContent(content)).toBe(
1267+
'<!DOCTYPE html><html lang="en"><head>' +
1268+
'<link rel="stylesheet" href="my-style" data-precedence="high"/>' +
1269+
'</head><body>Hello</body></html>',
1270+
);
1271+
});
1272+
1273+
// @gate enablePostpone
1274+
it('emits an empty prelude if a postpone in a promise in the shell', async () => {
1275+
let prerendering = true;
1276+
function Postpone() {
1277+
if (prerendering) {
1278+
React.unstable_postpone();
1279+
}
1280+
return 'Hello';
1281+
}
1282+
1283+
const Lazy = React.lazy(async () => {
1284+
await 0;
1285+
return {default: Postpone};
1286+
});
1287+
1288+
function App() {
1289+
return (
1290+
<html>
1291+
<link rel="stylesheet" href="my-style" precedence="high" />
1292+
<body>
1293+
<div>
1294+
<Lazy />
1295+
</div>
1296+
</body>
1297+
</html>
1298+
);
1299+
}
1300+
1301+
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
1302+
expect(prerendered.postponed).not.toBe(null);
1303+
1304+
prerendering = false;
1305+
1306+
expect(await readContent(prerendered.prelude)).toBe('');
1307+
1308+
const content = await ReactDOMFizzServer.resume(
1309+
<App />,
1310+
JSON.parse(JSON.stringify(prerendered.postponed)),
1311+
);
1312+
1313+
expect(await readContent(content)).toBe(
1314+
'<!DOCTYPE html><html><head>' +
1315+
'<link rel="stylesheet" href="my-style" data-precedence="high"/>' +
1316+
'</head><body><div>Hello</div></body></html>',
1317+
);
1318+
});
11901319
});

packages/react-noop-renderer/src/ReactNoopServer.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ const ReactNoopServer = ReactFizzServer({
9494
return null;
9595
},
9696

97+
resetResumableState(): void {},
98+
9799
pushTextInstance(
98100
target: Array<Uint8Array>,
99101
text: string,

packages/react-server/src/ReactFizzServer.js

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import {
7676
requestStorage,
7777
pushFormStateMarkerIsMatching,
7878
pushFormStateMarkerIsNotMatching,
79+
resetResumableState,
7980
} from './ReactFizzConfig';
8081
import {
8182
constructClassInstance,
@@ -505,6 +506,39 @@ export function resumeRequest(
505506
onFatalError: onFatalError === undefined ? noop : onFatalError,
506507
formState: null,
507508
};
509+
if (typeof postponedState.replaySlots === 'number') {
510+
const resumedId = postponedState.replaySlots;
511+
// We have a resume slot at the very root. This is effectively just a full rerender.
512+
const rootSegment = createPendingSegment(
513+
request,
514+
0,
515+
null,
516+
postponedState.rootFormatContext,
517+
// Root segments are never embedded in Text on either edge
518+
false,
519+
false,
520+
);
521+
rootSegment.id = resumedId;
522+
// There is no parent so conceptually, we're unblocked to flush this segment.
523+
rootSegment.parentFlushed = true;
524+
const rootTask = createRenderTask(
525+
request,
526+
null,
527+
children,
528+
-1,
529+
null,
530+
rootSegment,
531+
abortSet,
532+
null,
533+
postponedState.rootFormatContext,
534+
emptyContextObject,
535+
rootContextSnapshot,
536+
emptyTreeContext,
537+
);
538+
pingedTasks.push(rootTask);
539+
return request;
540+
}
541+
508542
const replay: ReplaySet = {
509543
nodes: postponedState.replayNodes,
510544
slots: postponedState.replaySlots,
@@ -2477,6 +2511,17 @@ function trackPostpone(
24772511

24782512
const keyPath = task.keyPath;
24792513
const boundary = task.blockedBoundary;
2514+
2515+
if (boundary === null) {
2516+
segment.id = request.nextSegmentId++;
2517+
trackedPostpones.rootSlots = segment.id;
2518+
if (request.completedRootSegment !== null) {
2519+
// Postpone the root if this was a deeper segment.
2520+
request.completedRootSegment.status = POSTPONED;
2521+
}
2522+
return;
2523+
}
2524+
24802525
if (boundary !== null && boundary.status === PENDING) {
24812526
boundary.status = POSTPONED;
24822527
// We need to eagerly assign it an ID because we'll need to refer to
@@ -2835,7 +2880,7 @@ function renderNode(
28352880
enablePostpone &&
28362881
request.trackedPostpones !== null &&
28372882
x.$$typeof === REACT_POSTPONE_TYPE &&
2838-
task.blockedBoundary !== null // TODO: Support holes in the shell
2883+
task.blockedBoundary !== null // bubble if we're postponing in the shell
28392884
) {
28402885
// If we're tracking postpones, we inject a hole here and continue rendering
28412886
// sibling. Similar to suspending. If we're not tracking, we treat it more like
@@ -3376,8 +3421,7 @@ function retryRenderTask(
33763421
} else if (
33773422
enablePostpone &&
33783423
request.trackedPostpones !== null &&
3379-
x.$$typeof === REACT_POSTPONE_TYPE &&
3380-
task.blockedBoundary !== null // TODO: Support holes in the shell
3424+
x.$$typeof === REACT_POSTPONE_TYPE
33813425
) {
33823426
// If we're tracking postpones, we mark this segment as postponed and finish
33833427
// the task without filling it in. If we're not tracking, we treat it more like
@@ -3870,7 +3914,10 @@ function flushCompletedQueues(
38703914
let i;
38713915
const completedRootSegment = request.completedRootSegment;
38723916
if (completedRootSegment !== null) {
3873-
if (request.pendingRootTasks === 0) {
3917+
if (completedRootSegment.status === POSTPONED) {
3918+
// We postponed the root, so we write nothing.
3919+
return;
3920+
} else if (request.pendingRootTasks === 0) {
38743921
if (enableFloat) {
38753922
writePreamble(
38763923
destination,
@@ -4138,6 +4185,13 @@ export function getPostponedState(request: Request): null | PostponedState {
41384185
request.trackedPostpones = null;
41394186
return null;
41404187
}
4188+
if (
4189+
request.completedRootSegment !== null &&
4190+
request.completedRootSegment.status === POSTPONED
4191+
) {
4192+
// We postponed the root so we didn't flush anything.
4193+
resetResumableState(request.resumableState, request.renderState);
4194+
}
41414195
return {
41424196
nextSegmentId: request.nextSegmentId,
41434197
rootFormatContext: request.rootFormatContext,

packages/react-server/src/forks/ReactFizzConfig.custom.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const isPrimaryRenderer = false;
3939
export const supportsRequestStorage = false;
4040
export const requestStorage: AsyncLocalStorage<Request> = (null: any);
4141

42+
export const resetResumableState = $$$config.resetResumableState;
4243
export const getChildFormatContext = $$$config.getChildFormatContext;
4344
export const makeId = $$$config.makeId;
4445
export const pushTextInstance = $$$config.pushTextInstance;

0 commit comments

Comments
 (0)