@samitouri / QOS-React-2 / commits / d177272802

[Fizz] Error and deopt from rel=expect for large documents without boundaries (#33454)

We want to make sure that we can block the reveal of a well designed complete shell reliably. In the Suspense model, client transitions don't have any way to implicitly resolve. This means you need to use Suspense or SuspenseList to explicitly split the document. Relying on implicit would mean you can't add a Suspense boundary later where needed. So we highly encourage the use of them around large content. However, if you have constructed a too large shell (e.g. by not adding any Suspense boundaries at all) then that might take too long to render on the client. We shouldn't punish users (or overzealous metrics tracking tools like search engines) in that scenario. This opts out of render blocking if the shell ends up too large to be intentional and too slow to load. Instead it deopts to showing the content split up in arbitrary ways (browser default). It only does this for SSR, and not client navs so it's not reliable. In fact, we issue an error to `onError`. This error is recoverable in that the document is still produced. It's up to your framework to decide if this errors the build or just surface it for action later. What should be the limit though? There's a trade off here. If this limit is too low then you can't fit a reasonably well built UI within it without getting errors. If it's too high then things that accidentally fall below it might take too long to load. I came up with 512kB of uncompressed shell HTML. See the comment in code for the rationale for this number. TL;DR: Data and theory indicates that having this much content inside `rel="expect"` doesn't meaningfully change metrics. Research of above-the-fold content on various websites indicate that this can comfortable fit all of them which should be enough for any intentional initial paint.

Sebastian Markbåge committed Jun 6, 2025 at 10:29 UTC d177272802b7f86a847312c23b7e60a6f56434de
8 files changed +199 -64
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+6 -2
@@ -5465,7 +5465,7 @@ export function writePreambleStart(
5465 destination: Destination,
5466 resumableState: ResumableState,
5467 renderState: RenderState,
5468 - skipExpect?: boolean, // Used as an override by ReactFizzConfigMarkup
5468 + skipBlockingShell: boolean,
5469 ): void {
5470 // This function must be called exactly once on every request
5471 if (enableFizzExternalRuntime && renderState.externalRuntimeScript) {
@@ -5549,7 +5549,7 @@ export function writePreambleStart(
5549 renderState.bulkPreloads.forEach(flushResource, destination);
5550 renderState.bulkPreloads.clear();
5551
5552 - if ((htmlChunks || headChunks) && !skipExpect) {
5552 + if ((htmlChunks || headChunks) && !skipBlockingShell) {
5553 // If we have any html or head chunks we know that we're rendering a full document.
5554 // A full document should block display until the full shell has downloaded.
5555 // Therefore we insert a render blocking instruction referring to the last body
@@ -5557,6 +5557,10 @@ export function writePreambleStart(
5557 // have already been emitted so we don't do anything to delay them but early so that
5558 // the browser doesn't risk painting too early.
5559 writeBlockingRenderInstruction(destination, resumableState, renderState);
5560 + } else {
5561 + // We don't need to add the shell id so mark it as if sent.
5562 + // Currently it might still be sent if it was already added to a bootstrap script.
5563 + resumableState.instructions |= SentCompletedShellId;
5564 }
5565
5566 // Write embedding hoistableChunks
packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
+15 -1
@@ -27,6 +27,7 @@ import {
27 writeStartClientRenderedSuspenseBoundary as writeStartClientRenderedSuspenseBoundaryImpl,
28 writeEndCompletedSuspenseBoundary as writeEndCompletedSuspenseBoundaryImpl,
29 writeEndClientRenderedSuspenseBoundary as writeEndClientRenderedSuspenseBoundaryImpl,
30 + writePreambleStart as writePreambleStartImpl,
31 } from './ReactFizzConfigDOM';
32
33 import type {
@@ -170,7 +171,6 @@ export {
171 createResumableState,
172 createPreambleState,
173 createHoistableState,
173 - writePreambleStart,
174 writePreambleEnd,
175 writeHoistables,
176 writePostamble,
@@ -311,5 +311,19 @@ export function writeEndClientRenderedSuspenseBoundary(
311 return writeEndClientRenderedSuspenseBoundaryImpl(destination, renderState);
312 }
313
314 +export function writePreambleStart(
315 + destination: Destination,
316 + resumableState: ResumableState,
317 + renderState: RenderState,
318 + skipBlockingShell: boolean,
319 +): void {
320 + return writePreambleStartImpl(
321 + destination,
322 + resumableState,
323 + renderState,
324 + true, // skipBlockingShell
325 + );
326 +}
327 +
328 export type TransitionStatus = FormStatus;
329 export const NotPendingTransition: TransitionStatus = NotPending;
packages/react-dom/src/__tests__/ReactDOMFizzServerEdge-test.js
+100
@@ -18,12 +18,14 @@ global.AsyncLocalStorage = require('async_hooks').AsyncLocalStorage;
18 let React;
19 let ReactDOM;
20 let ReactDOMFizzServer;
21 +let Suspense;
22
23 describe('ReactDOMFizzServerEdge', () => {
24 beforeEach(() => {
25 jest.resetModules();
26 jest.useRealTimers();
27 React = require('react');
28 + Suspense = React.Suspense;
29 ReactDOM = require('react-dom');
30 ReactDOMFizzServer = require('react-dom/server.edge');
31 });
@@ -81,4 +83,102 @@ describe('ReactDOMFizzServerEdge', () => {
83 );
84 }
85 });
86 +
87 + it('recoverably errors and does not add rel="expect" for large shells', async () => {
88 + function Paragraph() {
89 + return (
90 + <p>
91 + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris
92 + porttitor tortor ac lectus faucibus, eget eleifend elit hendrerit.
93 + Integer porttitor nisi in leo congue rutrum. Morbi sed ante posuere,
94 + aliquam lorem ac, imperdiet orci. Duis malesuada gravida pharetra.
95 + Cras facilisis arcu diam, id dictum lorem imperdiet a. Suspendisse
96 + aliquet tempus tortor et ultricies. Aliquam libero velit, posuere
97 + tempus ante sed, pellentesque tincidunt lorem. Nullam iaculis, eros a
98 + varius aliquet, tortor felis tempor metus, nec cursus felis eros
99 + aliquam nulla. Vivamus ut orci sed mauris congue lacinia. Cras eget
100 + blandit neque. Pellentesque a massa in turpis ullamcorper volutpat vel
101 + at massa. Sed ante est, auctor non diam non, vulputate ultrices metus.
102 + Maecenas dictum fermentum quam id aliquam. Donec porta risus vitae
103 + pretium posuere. Fusce facilisis eros in lacus tincidunt congue.
104 + </p>
105 + );
106 + }
107 +
108 + function App({suspense}) {
109 + const paragraphs = [];
110 + for (let i = 0; i < 600; i++) {
111 + paragraphs.push(<Paragraph key={i} />);
112 + }
113 + return (
114 + <html>
115 + <body>
116 + {suspense ? (
117 + // This is ok
118 + <Suspense fallback="Loading">{paragraphs}</Suspense>
119 + ) : (
120 + // This is not
121 + paragraphs
122 + )}
123 + </body>
124 + </html>
125 + );
126 + }
127 + const errors = [];
128 + const stream = await ReactDOMFizzServer.renderToReadableStream(
129 + <App suspense={false} />,
130 + {
131 + onError(error) {
132 + errors.push(error);
133 + },
134 + },
135 + );
136 + const result = await readResult(stream);
137 + expect(result).not.toContain('rel="expect"');
138 + if (gate(flags => flags.enableFizzBlockingRender)) {
139 + expect(errors.length).toBe(1);
140 + expect(errors[0].message).toContain(
141 + 'This rendered a large document (>512) without any Suspense boundaries around most of it.',
142 + );
143 + } else {
144 + expect(errors.length).toBe(0);
145 + }
146 +
147 + // If we wrap in a Suspense boundary though, then it should be ok.
148 + const errors2 = [];
149 + const stream2 = await ReactDOMFizzServer.renderToReadableStream(
150 + <App suspense={true} />,
151 + {
152 + onError(error) {
153 + errors2.push(error);
154 + },
155 + },
156 + );
157 + const result2 = await readResult(stream2);
158 + if (gate(flags => flags.enableFizzBlockingRender)) {
159 + expect(result2).toContain('rel="expect"');
160 + } else {
161 + expect(result2).not.toContain('rel="expect"');
162 + }
163 + expect(errors2.length).toBe(0);
164 +
165 + // Or if we increase the progressiveChunkSize.
166 + const errors3 = [];
167 + const stream3 = await ReactDOMFizzServer.renderToReadableStream(
168 + <App suspense={false} />,
169 + {
170 + progressiveChunkSize: 100000,
171 + onError(error) {
172 + errors3.push(error);
173 + },
174 + },
175 + );
176 + const result3 = await readResult(stream3);
177 + if (gate(flags => flags.enableFizzBlockingRender)) {
178 + expect(result3).toContain('rel="expect"');
179 + } else {
180 + expect(result3).not.toContain('rel="expect"');
181 + }
182 + expect(errors3.length).toBe(0);
183 + });
184 });
packages/react-dom/src/__tests__/ReactDOMLegacyFloat-test.js
-6
@@ -35,13 +35,7 @@ describe('ReactDOMFloat', () => {
35
36 expect(result).toEqual(
37 '<html><head><meta charSet="utf-8"/>' +
38 - (gate(flags => flags.enableFizzBlockingRender)
39 - ? '<link rel="expect" href="#_R_" blocking="render"/>'
40 - : '') +
38 '<title>title</title><script src="foo"></script></head>' +
42 - (gate(flags => flags.enableFizzBlockingRender)
43 - ? '<template id="_R_"></template>'
44 - : '') +
39 '</html>',
40 );
41 });
packages/react-dom/src/__tests__/ReactRenderDocument-test.js
+10 -51
@@ -70,6 +70,7 @@ describe('rendering React components at document', () => {
70
71 const markup = ReactDOMServer.renderToString(<Root hello="world" />);
72 expect(markup).not.toContain('DOCTYPE');
73 + expect(markup).not.toContain('rel="expect"');
74 const testDocument = getTestDocument(markup);
75 const body = testDocument.body;
76
@@ -77,22 +78,12 @@ describe('rendering React components at document', () => {
78 await act(() => {
79 root = ReactDOMClient.hydrateRoot(testDocument, <Root hello="world" />);
80 });
80 - expect(testDocument.body.innerHTML).toBe(
81 - 'Hello world' +
82 - (gate(flags => flags.enableFizzBlockingRender)
83 - ? '<template id="_R_"></template>'
84 - : ''),
85 - );
81 + expect(testDocument.body.innerHTML).toBe('Hello world');
82
83 await act(() => {
84 root.render(<Root hello="moon" />);
85 });
90 - expect(testDocument.body.innerHTML).toBe(
91 - 'Hello moon' +
92 - (gate(flags => flags.enableFizzBlockingRender)
93 - ? '<template id="_R_"></template>'
94 - : ''),
95 - );
86 + expect(testDocument.body.innerHTML).toBe('Hello moon');
87
88 expect(body === testDocument.body).toBe(true);
89 });
@@ -117,12 +108,7 @@ describe('rendering React components at document', () => {
108 await act(() => {
109 root = ReactDOMClient.hydrateRoot(testDocument, <Root />);
110 });
120 - expect(testDocument.body.innerHTML).toBe(
121 - 'Hello world' +
122 - (gate(flags => flags.enableFizzBlockingRender)
123 - ? '<template id="_R_"></template>'
124 - : ''),
125 - );
111 + expect(testDocument.body.innerHTML).toBe('Hello world');
112
113 const originalDocEl = testDocument.documentElement;
114 const originalHead = testDocument.head;
@@ -133,16 +119,8 @@ describe('rendering React components at document', () => {
119 expect(testDocument.firstChild).toBe(originalDocEl);
120 expect(testDocument.head).toBe(originalHead);
121 expect(testDocument.body).toBe(originalBody);
136 - expect(originalBody.innerHTML).toBe(
137 - gate(flags => flags.enableFizzBlockingRender)
138 - ? '<template id="_R_"></template>'
139 - : '',
140 - );
141 - expect(originalHead.innerHTML).toBe(
142 - gate(flags => flags.enableFizzBlockingRender)
143 - ? '<link rel="expect" href="#_R_" blocking="render">'
144 - : '',
145 - );
122 + expect(originalBody.innerHTML).toBe('');
123 + expect(originalHead.innerHTML).toBe('');
124 });
125
126 it('should not be able to switch root constructors', async () => {
@@ -180,22 +158,13 @@ describe('rendering React components at document', () => {
158 root = ReactDOMClient.hydrateRoot(testDocument, <Component />);
159 });
160
183 - expect(testDocument.body.innerHTML).toBe(
184 - 'Hello world' +
185 - (gate(flags => flags.enableFizzBlockingRender)
186 - ? '<template id="_R_"></template>'
187 - : ''),
188 - );
161 + expect(testDocument.body.innerHTML).toBe('Hello world');
162
163 await act(() => {
164 root.render(<Component2 />);
165 });
166
194 - expect(testDocument.body.innerHTML).toBe(
195 - (gate(flags => flags.enableFizzBlockingRender)
196 - ? '<template id="_R_"></template>'
197 - : '') + 'Goodbye world',
198 - );
167 + expect(testDocument.body.innerHTML).toBe('Goodbye world');
168 });
169
170 it('should be able to mount into document', async () => {
@@ -224,12 +193,7 @@ describe('rendering React components at document', () => {
193 );
194 });
195
227 - expect(testDocument.body.innerHTML).toBe(
228 - 'Hello world' +
229 - (gate(flags => flags.enableFizzBlockingRender)
230 - ? '<template id="_R_"></template>'
231 - : ''),
232 - );
196 + expect(testDocument.body.innerHTML).toBe('Hello world');
197 });
198
199 it('cannot render over an existing text child at the root', async () => {
@@ -362,12 +326,7 @@ describe('rendering React components at document', () => {
326 : [],
327 );
328 expect(testDocument.body.innerHTML).toBe(
365 - favorSafetyOverHydrationPerf
366 - ? 'Hello world'
367 - : 'Goodbye world' +
368 - (gate(flags => flags.enableFizzBlockingRender)
369 - ? '<template id="_R_"></template>'
370 - : ''),
329 + favorSafetyOverHydrationPerf ? 'Hello world' : 'Goodbye world',
330 );
331 });
332
packages/react-markup/src/ReactFizzConfigMarkup.js
+2 -2
@@ -222,13 +222,13 @@ export function writePreambleStart(
222 destination: Destination,
223 resumableState: ResumableState,
224 renderState: RenderState,
225 - skipExpect?: boolean, // Used as an override by ReactFizzConfigMarkup
225 + skipBlockingShell: boolean,
226 ): void {
227 return writePreambleStartImpl(
228 destination,
229 resumableState,
230 renderState,
231 - true, // skipExpect
231 + true, // skipBlockingShell
232 );
233 }
234
packages/react-server/src/ReactFizzServer.js
+64 -1
@@ -182,6 +182,7 @@ import {
182 disableDefaultPropsExceptForClasses,
183 enableAsyncIterableChildren,
184 enableViewTransition,
185 + enableFizzBlockingRender,
186 } from 'shared/ReactFeatureFlags';
187
188 import assign from 'shared/assign';
@@ -418,6 +419,41 @@ type Preamble = PreambleState;
419 // 500 * 1024 / 8 * .8 * 0.5 / 2
420 const DEFAULT_PROGRESSIVE_CHUNK_SIZE = 12800;
421
422 +function getBlockingRenderMaxSize(request: Request): number {
423 + // We want to make sure that we can block the reveal of a well designed complete
424 + // shell but if you have constructed a too large shell (e.g. by not adding any
425 + // Suspense boundaries) then that might take too long to render. We shouldn't
426 + // punish users (or overzealous metrics tracking) in that scenario.
427 + // There's a trade off here. If this limit is too low then you can't fit a
428 + // reasonably well built UI within it without getting errors. If it's too high
429 + // then things that accidentally fall below it might take too long to load.
430 + // Web Vitals target 1.8 seconds for first paint and our goal to have the limit
431 + // be fast enough to hit that. For this argument we assume that most external
432 + // resources are already cached because it's a return visit, or inline styles.
433 + // If it's not, then it's highly unlikely that any render blocking instructions
434 + // we add has any impact what so ever on the paint.
435 + // Assuming a first byte of about 600ms which is kind of bad but common with a
436 + // decent static host. If it's longer e.g. due to dynamic rendering, then you
437 + // are going to bound by dynamic production of the content and you're better off
438 + // with Suspense boundaries anyway. This number doesn't matter much. Then you
439 + // have about 1.2 seconds left for bandwidth. On 3G that gives you about 112.5kb
440 + // worth of data. That's worth about 10x in terms of uncompressed bytes. Then we
441 + // half that just to account for longer latency, slower bandwidth and CPU processing.
442 + // Now we're down to about 500kb. In fact, looking at metrics we've collected with
443 + // rel="expect" examples and other documents, the impact on documents smaller than
444 + // that is within the noise. That's because there's enough happening within that
445 + // start up to not make HTML streaming not significantly better.
446 + // Content above the fold tends to be about 100-200kb tops. Therefore 500kb should
447 + // be enough head room for a good loading state. After that you should use
448 + // Suspense or SuspenseList to improve it.
449 + // Since this is highly related to the reason you would adjust the
450 + // progressiveChunkSize option, and always has to be higher, we define this limit
451 + // in terms of it. So if you want to increase the limit because you have high
452 + // bandwidth users, then you can adjust it up. If you are concerned about even
453 + // slower bandwidth then you can adjust it down.
454 + return request.progressiveChunkSize * 40; // 512kb by default.
455 +}
456 +
457 function isEligibleForOutlining(
458 request: Request,
459 boundary: SuspenseBoundary,
@@ -5476,9 +5512,15 @@ function flushPreamble(
5512 destination: Destination,
5513 rootSegment: Segment,
5514 preambleSegments: Array<Array<Segment>>,
5515 + skipBlockingShell: boolean,
5516 ) {
5517 // The preamble is ready.
5481 - writePreambleStart(destination, request.resumableState, request.renderState);
5518 + writePreambleStart(
5519 + destination,
5520 + request.resumableState,
5521 + request.renderState,
5522 + skipBlockingShell,
5523 + );
5524 for (let i = 0; i < preambleSegments.length; i++) {
5525 const segments = preambleSegments[i];
5526 for (let j = 0; j < segments.length; j++) {
@@ -5888,11 +5930,32 @@ function flushCompletedQueues(
5930
5931 flushedByteSize = request.byteSize; // Start counting bytes
5932 // TODO: Count the size of the preamble chunks too.
5933 + let skipBlockingShell = false;
5934 + if (enableFizzBlockingRender) {
5935 + const blockingRenderMaxSize = getBlockingRenderMaxSize(request);
5936 + if (flushedByteSize > blockingRenderMaxSize) {
5937 + skipBlockingShell = true;
5938 + const maxSizeKb = Math.round(blockingRenderMaxSize / 1000);
5939 + const error = new Error(
5940 + 'This rendered a large document (>' +
5941 + maxSizeKb +
5942 + ') without any Suspense ' +
5943 + 'boundaries around most of it. That can delay initial paint longer than ' +
5944 + 'necessary. To improve load performance, add a <Suspense> or <SuspenseList> ' +
5945 + 'around the content you expect to be below the header or below the fold. ' +
5946 + 'In the meantime, the content will deopt to paint arbitrary incomplete ' +
5947 + 'pieces of HTML.',
5948 + );
5949 + const errorInfo: ThrownInfo = {};
5950 + logRecoverableError(request, error, errorInfo, null);
5951 + }
5952 + }
5953 flushPreamble(
5954 request,
5955 destination,
5956 completedRootSegment,
5957 completedPreambleSegments,
5958 + skipBlockingShell,
5959 );
5960 flushSegment(request, destination, completedRootSegment, null);
5961 request.completedRootSegment = null;
scripts/error-codes/codes.json
+2 -1
@@ -545,5 +545,6 @@
545 "557": "Expected to have a hydrated activity instance. This error is likely caused by a bug in React. Please file an issue.",
546 "558": "Client rendering an Activity suspended it again. This is a bug in React.",
547 "559": "Expected to find a host node. This is a bug in React.",
548 - "560": "Cannot use a startGestureTransition() with a comment node root."
548 + "560": "Cannot use a startGestureTransition() with a comment node root.",
549 + "561": "This rendered a large document (>%s) without any Suspense boundaries around most of it. That can delay initial paint longer than necessary. To improve load performance, add a <Suspense> or <SuspenseList> around the content you expect to be below the header or below the fold. In the meantime, the content will deopt to paint arbitrary incomplete pieces of HTML."
550 }