@samitouri / QOS-React-1 / commits / 05fbd1aab0

[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.

Sebastian Markbåge committed Oct 23, 2023 at 12:53 UTC 05fbd1aab036ecbd14469d6376024439bc931f68
6 files changed +214 -4
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+23
@@ -576,6 +576,29 @@ export function createResumableState(
576 };
577 }
578
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 +
602 // Constants for the insertion mode we're currently writing in. We don't encode all HTML5 insertion
603 // modes. We only include the variants as they matter for the sake of our purposes.
604 // We don't actually provide the namespace therefore we use constants instead of the string.
packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
+1
@@ -158,6 +158,7 @@ export {
158 hoistResources,
159 setCurrentlyRenderingBoundaryResourcesTarget,
160 prepareHostDispatcher,
161 + resetResumableState,
162 } from './ReactFizzConfigDOM';
163
164 import escapeTextForBrowser from './escapeTextForBrowser';
packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js
+129
@@ -1187,4 +1187,133 @@ describe('ReactDOMFizzStaticBrowser', () => {
1187
1188 expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
1189 });
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 + });
1319 });
packages/react-noop-renderer/src/ReactNoopServer.js
+2
@@ -94,6 +94,8 @@ const ReactNoopServer = ReactFizzServer({
94 return null;
95 },
96
97 + resetResumableState(): void {},
98 +
99 pushTextInstance(
100 target: Array<Uint8Array>,
101 text: string,
packages/react-server/src/ReactFizzServer.js
+58 -4
@@ -76,6 +76,7 @@ import {
76 requestStorage,
77 pushFormStateMarkerIsMatching,
78 pushFormStateMarkerIsNotMatching,
79 + resetResumableState,
80 } from './ReactFizzConfig';
81 import {
82 constructClassInstance,
@@ -505,6 +506,39 @@ export function resumeRequest(
506 onFatalError: onFatalError === undefined ? noop : onFatalError,
507 formState: null,
508 };
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 +
542 const replay: ReplaySet = {
543 nodes: postponedState.replayNodes,
544 slots: postponedState.replaySlots,
@@ -2477,6 +2511,17 @@ function trackPostpone(
2511
2512 const keyPath = task.keyPath;
2513 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 +
2525 if (boundary !== null && boundary.status === PENDING) {
2526 boundary.status = POSTPONED;
2527 // We need to eagerly assign it an ID because we'll need to refer to
@@ -2835,7 +2880,7 @@ function renderNode(
2880 enablePostpone &&
2881 request.trackedPostpones !== null &&
2882 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
2884 ) {
2885 // If we're tracking postpones, we inject a hole here and continue rendering
2886 // sibling. Similar to suspending. If we're not tracking, we treat it more like
@@ -3376,8 +3421,7 @@ function retryRenderTask(
3421 } else if (
3422 enablePostpone &&
3423 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
3425 ) {
3426 // If we're tracking postpones, we mark this segment as postponed and finish
3427 // the task without filling it in. If we're not tracking, we treat it more like
@@ -3870,7 +3914,10 @@ function flushCompletedQueues(
3914 let i;
3915 const completedRootSegment = request.completedRootSegment;
3916 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) {
3921 if (enableFloat) {
3922 writePreamble(
3923 destination,
@@ -4138,6 +4185,13 @@ export function getPostponedState(request: Request): null | PostponedState {
4185 request.trackedPostpones = null;
4186 return null;
4187 }
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 + }
4195 return {
4196 nextSegmentId: request.nextSegmentId,
4197 rootFormatContext: request.rootFormatContext,
packages/react-server/src/forks/ReactFizzConfig.custom.js
+1
@@ -39,6 +39,7 @@ export const isPrimaryRenderer = false;
39 export const supportsRequestStorage = false;
40 export const requestStorage: AsyncLocalStorage<Request> = (null: any);
41
42 +export const resetResumableState = $$$config.resetResumableState;
43 export const getChildFormatContext = $$$config.getChildFormatContext;
44 export const makeId = $$$config.makeId;
45 export const pushTextInstance = $$$config.pushTextInstance;