@samitouri / QOS-React-1 / commits / b1786c319e

[Fizz] Finalize postponed nextSegmentId after the prelude flush (#36779)

`getPostponedState` snapshots `request.nextSegmentId` at `onAllReady`, before the Fizz stream is flowing. At this point we have visited all Suspense boundaries and know which ones suspended by user code or not. However, only when the stream is flowing are we counting the size of each boundary. When we detect large boundaries, we suspend them i.e. we outline them instead of keeping them inline. This results in more segments being written while the postponed state holds a stale count. We now keep a reference to the returned postponed state and mutate the segment IDs when we outline. That way serializing `postponed` after the `prelude` has flushed writes the latest postponed state. The current API design means that you can potentially serialize stale postponed state. We're considering a redesign to make these issues impossible (e.g. https://github.com/react/react/pull/36815). For now, the postponed state should only be serialized or passed onto a `resume` once the `prelude` has flushed e.g.: ```js const { createWriteStream, writeFileSync } = require('node:fs'); const { createWriteStream } = require('node:stream'); const { prelude, postponed } = prenderToNodeStream(...) // serializing `postponed` now would write a stale state. // serialize prelude const destination = createWriteStream('prelude.html') prelude.pipe(destination) await finished(prelude) // now we can serialize postponed writeFileSync('postponed.json', JSON.stringify(postponed)) ``` --------- Co-authored-by: Sebastian Sebbie Silbermann <sebastian.silbermann@vercel.com>

Jude Gao committed Jun 18, 2026 at 23:07 UTC b1786c319e5647678d7fb9922743f56078062b73
2 files changed +134 -8
packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js
+89
@@ -1105,6 +1105,95 @@ describe('ReactDOMFizzStaticBrowser', () => {
1105 );
1106 });
1107
1108 + it('reveals a resumed boundary even when the shell outlined a completed boundary', async () => {
1109 + // Regression test for a segment-id collision between the prerendered shell
1110 + // and the resume. The prelude flush outlines a large *completed* boundary
1111 + // into the shell, advancing request.nextSegmentId past the value
1112 + // getPostponedState snapshotted before the flush. If that snapshot isn't
1113 + // finalized after the flush, the resume re-allocates ids the shell already
1114 + // used; in the served document $RC (getElementById, first match) then
1115 + // reveals the wrong element and the resumed boundary stays on its fallback.
1116 + // Asserts on the rendered output rather than the segment ids.
1117 + let prerendering = true;
1118 + const shellText = 'a'.repeat(800); // > 500 bytes => eligible for outlining
1119 + const resumeText = 'b'.repeat(800);
1120 +
1121 + // Completes during the prerender; large enough that the prelude flush
1122 + // outlines it into the shell.
1123 + function ShellBoundary() {
1124 + return <div>{shellText}</div>;
1125 + }
1126 +
1127 + // Suspends during the prerender so its boundary becomes a hole the resume
1128 + // fills. On resume it renders a nested large boundary that itself outlines,
1129 + // so the resume allocates fresh segment ids from the postponed seed.
1130 + function Hole() {
1131 + if (prerendering) {
1132 + return React.use(theInfinitePromise);
1133 + }
1134 + return (
1135 + <Suspense fallback="LoadingC">
1136 + <div>{resumeText}</div>
1137 + </Suspense>
1138 + );
1139 + }
1140 +
1141 + function App() {
1142 + return (
1143 + <div>
1144 + <Suspense fallback="LoadingA">
1145 + <ShellBoundary />
1146 + </Suspense>
1147 + <Suspense fallback="LoadingB">
1148 + <Hole />
1149 + </Suspense>
1150 + </div>
1151 + );
1152 + }
1153 +
1154 + const controller = new AbortController();
1155 + let pendingResult;
1156 + await serverAct(() => {
1157 + pendingResult = ReactDOMFizzStatic.prerender(<App />, {
1158 + signal: controller.signal,
1159 + progressiveChunkSize: 100, // force the completed boundary to outline
1160 + onError() {},
1161 + });
1162 + });
1163 + await serverAct(() => controller.abort());
1164 + const prerendered = await pendingResult;
1165 + expect(prerendered.postponed).not.toBe(null);
1166 +
1167 + const shellHTML = await readContent(prerendered.prelude);
1168 +
1169 + prerendering = false;
1170 + const resumed = await serverAct(() =>
1171 + ReactDOMFizzServer.resume(
1172 + <App />,
1173 + JSON.parse(JSON.stringify(prerendered.postponed)),
1174 + {onError() {}},
1175 + ),
1176 + );
1177 + const resumeHTML = await readContent(resumed);
1178 +
1179 + // Run the shell and the resume as one served document so the completion
1180 + // instructions ($RC) execute against both together, the way a browser does.
1181 + const temp = document.createElement('div');
1182 + temp.innerHTML = shellHTML + resumeHTML;
1183 + await insertNodesAndExecuteScripts(temp, container, null);
1184 + jest.runAllTimers();
1185 +
1186 + // Both boundaries reveal their own content. Without the fix the resumed
1187 + // boundary reuses the shell's ids, so its $RC resolves to the shell's
1188 + // element and it stays on its "LoadingC" fallback.
1189 + expect(getVisibleChildren(container)).toEqual(
1190 + <div>
1191 + <div>{shellText}</div>
1192 + <div>{resumeText}</div>
1193 + </div>,
1194 + );
1195 + });
1196 +
1197 it('can omit a preamble with an empty shell if no preamble is ready when prerendering finishes', async () => {
1198 const errors = [];
1199
packages/react-server/src/ReactFizzServer.js
+45 -8
@@ -394,6 +394,15 @@ export opaque type Request = {
394 completedBoundaries: Array<SuspenseBoundary>, // Completed but not yet fully flushed boundaries to show.
395 partialBoundaries: Array<SuspenseBoundary>, // Partially completed boundaries that can flush its segments early.
396 trackedPostpones: null | PostponedHoles, // Gets set to non-null while we want to track postponed holes. I.e. during a prerender.
397 + // While prerendering a postponed request that produced a real shell, this
398 + // holds the PostponedState returned by getPostponedState. getPostponedState
399 + // snapshots nextSegmentId before the (pull-driven) prelude flush runs, but the
400 + // flush outlines completed boundaries and advances nextSegmentId past that
401 + // snapshot. We finalize the snapshot from flushCompletedQueues so the resumed
402 + // render allocates segment ids strictly above the shell's; otherwise the shell
403 + // and resume emit duplicate B:/S: ids once concatenated. Stays null for live
404 + // renders and resumes.
405 + postponedState: null | PostponedState,
406 // onError is called when an error happens anywhere in the tree. It might recover.
407 // The return string is used in production primarily to avoid leaking internals, secondarily to save bytes.
408 // Returning null/undefined will cause a default error message in production
@@ -553,6 +562,7 @@ function RequestInstance(
562 this.completedBoundaries = [] as Array<SuspenseBoundary>;
563 this.partialBoundaries = [] as Array<SuspenseBoundary>;
564 this.trackedPostpones = null;
565 + this.postponedState = null;
566 this.onError = onError === undefined ? defaultErrorHandler : onError;
567 this.onAllReady = onAllReady === undefined ? noop : onAllReady;
568 this.onShellReady = onShellReady === undefined ? noop : onShellReady;
@@ -6146,6 +6156,19 @@ function flushCompletedQueues(
6156 largeBoundaries.splice(0, i);
6157 } finally {
6158 flushingPartialBoundaries = false;
6159 + const postponedState = request.postponedState;
6160 + if (postponedState !== null) {
6161 + // The shell flush above may have outlined completed boundaries, advancing
6162 + // nextSegmentId past the value getPostponedState snapshotted before the
6163 + // flush. Re-sync the postponed state to the post-flush high-water mark so
6164 + // the resume allocates segment ids strictly above the shell's and can't
6165 + // emit duplicate B:/S: ids. nextSegmentId only increases, so later flush
6166 + // passes refine this monotonically.
6167 + // TODO: Could be too late if the postponed state was already serialized
6168 + // by API consumers. Accessing postponed state before the prelude has flushed
6169 + // should be forbidden in the API.
6170 + postponedState.nextSegmentId = request.nextSegmentId;
6171 + }
6172 if (
6173 request.allPendingTasks === 0 &&
6174 request.clientRenderedBoundaries.length === 0 &&
@@ -6423,13 +6446,16 @@ export function getPostponedState(request: Request): null | PostponedState {
6446 }
6447 let replaySlots: ResumeSlots;
6448 let nextSegmentId: number;
6426 - if (
6427 - request.completedRootSegment !== null &&
6428 - // The Root postponed
6429 - (request.completedRootSegment.status === POSTPONED ||
6430 - // Or the Preamble was not available
6431 - request.completedPreambleSegments === null)
6432 - ) {
6449 + // True only when we postponed with a real shell (as opposed to postponing the
6450 + // root itself). Only in that case does the prelude flush outline completed
6451 + // boundaries and advance nextSegmentId past the value we capture here.
6452 + const hasFlushableShell =
6453 + request.completedRootSegment === null ||
6454 + // The Root did not postpone
6455 + (request.completedRootSegment.status !== POSTPONED &&
6456 + // the Preamble was available
6457 + request.completedPreambleSegments !== null);
6458 + if (!hasFlushableShell) {
6459 nextSegmentId = 0;
6460 // We need to ensure that on resume we retry the root. We use a number
6461 // type for the replaySlots to signify this (see resumeRequest).
@@ -6443,7 +6469,7 @@ export function getPostponedState(request: Request): null | PostponedState {
6469 replaySlots = trackedPostpones.rootSlots;
6470 completeResumableState(request.resumableState);
6471 }
6446 - return {
6472 + const postponedState: PostponedState = {
6473 nextSegmentId,
6474 rootFormatContext: request.rootFormatContext,
6475 progressiveChunkSize: request.progressiveChunkSize,
@@ -6451,4 +6477,15 @@ export function getPostponedState(request: Request): null | PostponedState {
6477 replayNodes: trackedPostpones.rootNodes,
6478 replaySlots,
6479 };
6480 + if (hasFlushableShell) {
6481 + // The prelude hasn't flushed yet — it's pull-driven and runs after this
6482 + // (see completeAll -> onAllReady). Flushing the shell outlines completed
6483 + // boundaries, bumping request.nextSegmentId beyond the snapshot above. Hold
6484 + // a reference so flushCompletedQueues can finalize nextSegmentId to the
6485 + // post-flush high-water mark. Otherwise the resume reuses the shell's
6486 + // segment ids and the concatenated document has duplicate B:/S: ids, which
6487 + // cross-wires React's boundary-completion scripts ($RC).
6488 + request.postponedState = postponedState;
6489 + }
6490 + return postponedState;
6491 }