@samitouri / QOS-React-2 / commits / 143d3e1b89

[Fizz] Emit link rel="expect" to block render before the shell has fully loaded (#33016)

The semantics of React is that anything outside of Suspense boundaries in a transition doesn't display until it has fully unsuspended. With SSR streaming the intention is to preserve that. We explicitly don't want to support the mode of document streaming normally supported by the browser where it can paint content as tags stream in since that leads to content popping in and thrashing in unpredictable ways. This should instead be modeled explictly by nested Suspense boundaries or something like SuspenseList. After the first shell any nested Suspense boundaries are only revealed, by script, once they're fully streamed in to the next boundary. So this is already the case there. However, for the initial shell we have been at the mercy of browser heuristics for how long it decides to stream before the first paint. Chromium now has [an API explicitly for this use case](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API/Using#stabilizing_page_state_to_make_cross-document_transitions_consistent) that lets us model the semantics that we want. This is always important but especially so with MPA View Transitions. After this a simple document looks like this: ```html <!DOCTYPE html> <html> <head> <link rel="expect" href="#«R»" blocking="render"/> </head> <body> <p>hello world</p> <script src="bootstrap.js" id="«R»" async=""></script> ... </body> </html> ``` The `rel="expect"` tag indicates that we want to wait to paint until we have streamed far enough to be able to paint the id `"«R»"` which indicates the shell. Ideally this `id` would be assigned to the root most HTML element in the body. However, this is tricky in our implementation because there can be multiple and we can render them out of order. So instead, we assign the id to the first bootstrap script if there is one since these are always added to the end of the shell. If there isn't a bootstrap script then we emit an empty `<template id="«R»"></template>` instead as a marker. Since we currently put as much as possible in the shell if it's loaded by the time we render, this can have some negative effects for very large documents. We should instead apply the heuristic where very large Suspense boundaries get outlined outside the shell even if they're immediately available. This means that even prerenders can end up with script tags. We only emit the `rel="expect"` if you're rendering a whole document. I.e. if you rendered either a `<html>` or `<head>` tag. If you're rendering a partial document, then we don't really know where the streaming parts are anyway and can't provide such guarantees. This does apply whether you're streaming or not because we still want to block rendering until the end, but in practice any serialized state that needs hydrate should still be embedded after the completion id.

Sebastian Markbåge committed Apr 25, 2025 at 11:52 UTC 143d3e1b89d7f64d607bbfc844d1324b39ed93dc
20 files changed +274 -66
fixtures/ssr/server/render.js
+35 -1
@@ -1,5 +1,6 @@
1 import React from 'react';
2 import {renderToPipeableStream} from 'react-dom/server';
3 +import {Writable} from 'stream';
4
5 import App from '../src/components/App';
6
@@ -14,11 +15,41 @@ if (process.env.NODE_ENV === 'development') {
15 assets = require('../build/asset-manifest.json');
16 }
17
18 +class ThrottledWritable extends Writable {
19 + constructor(destination) {
20 + super();
21 + this.destination = destination;
22 + this.delay = 150;
23 + }
24 +
25 + _write(chunk, encoding, callback) {
26 + let o = 0;
27 + const write = () => {
28 + this.destination.write(chunk.slice(o, o + 100), encoding, x => {
29 + o += 100;
30 + if (o < chunk.length) {
31 + setTimeout(write, this.delay);
32 + } else {
33 + callback(x);
34 + }
35 + });
36 + };
37 + setTimeout(write, this.delay);
38 + }
39 +
40 + _final(callback) {
41 + setTimeout(() => {
42 + this.destination.end(callback);
43 + }, this.delay);
44 + }
45 +}
46 +
47 export default function render(url, res) {
48 res.socket.on('error', error => {
49 // Log fatal errors
50 console.error('Fatal', error);
51 });
52 + console.log('hello');
53 let didError = false;
54 const {pipe, abort} = renderToPipeableStream(<App assets={assets} />, {
55 bootstrapScripts: [assets['main.js']],
@@ -26,7 +57,10 @@ export default function render(url, res) {
57 // If something errored before we started streaming, we set the error code appropriately.
58 res.statusCode = didError ? 500 : 200;
59 res.setHeader('Content-type', 'text/html');
29 - pipe(res);
60 + // To test the actual chunks taking time to load over the network, we throttle
61 + // the stream a bit.
62 + const throttledResponse = new ThrottledWritable(res);
63 + pipe(throttledResponse);
64 },
65 onShellError(x) {
66 // Something errored before we could complete the shell so we emit an alternative shell.
fixtures/ssr/src/components/Chrome.js
+1
@@ -37,6 +37,7 @@ export default class Chrome extends Component {
37 </div>
38 </Theme.Provider>
39 </Suspense>
40 + <p>This should appear in the first paint.</p>
41 <script
42 dangerouslySetInnerHTML={{
43 __html: `assetManifest = ${JSON.stringify(assets)};`,
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+117 -19
@@ -120,12 +120,13 @@ const ScriptStreamingFormat: StreamingFormat = 0;
120 const DataStreamingFormat: StreamingFormat = 1;
121
122 export type InstructionState = number;
123 -const NothingSent /* */ = 0b00000;
124 -const SentCompleteSegmentFunction /* */ = 0b00001;
125 -const SentCompleteBoundaryFunction /* */ = 0b00010;
126 -const SentClientRenderFunction /* */ = 0b00100;
127 -const SentStyleInsertionFunction /* */ = 0b01000;
128 -const SentFormReplayingRuntime /* */ = 0b10000;
123 +const NothingSent /* */ = 0b000000;
124 +const SentCompleteSegmentFunction /* */ = 0b000001;
125 +const SentCompleteBoundaryFunction /* */ = 0b000010;
126 +const SentClientRenderFunction /* */ = 0b000100;
127 +const SentStyleInsertionFunction /* */ = 0b001000;
128 +const SentFormReplayingRuntime /* */ = 0b010000;
129 +const SentCompletedShellId /* */ = 0b100000;
130
131 // Per request, global state that is not contextual to the rendering subtree.
132 // This cannot be resumed and therefore should only contain things that are
@@ -289,15 +290,15 @@ export type ResumableState = {
290
291 const dataElementQuotedEnd = stringToPrecomputedChunk('"></template>');
292
292 -const startInlineScript = stringToPrecomputedChunk('<script>');
293 +const startInlineScript = stringToPrecomputedChunk('<script');
294 const endInlineScript = stringToPrecomputedChunk('</script>');
295
296 const startScriptSrc = stringToPrecomputedChunk('<script src="');
297 const startModuleSrc = stringToPrecomputedChunk('<script type="module" src="');
297 -const scriptNonce = stringToPrecomputedChunk('" nonce="');
298 -const scriptIntegirty = stringToPrecomputedChunk('" integrity="');
299 -const scriptCrossOrigin = stringToPrecomputedChunk('" crossorigin="');
300 -const endAsyncScript = stringToPrecomputedChunk('" async=""></script>');
298 +const scriptNonce = stringToPrecomputedChunk(' nonce="');
299 +const scriptIntegirty = stringToPrecomputedChunk(' integrity="');
300 +const scriptCrossOrigin = stringToPrecomputedChunk(' crossorigin="');
301 +const endAsyncScript = stringToPrecomputedChunk(' async=""></script>');
302
303 /**
304 * This escaping function is designed to work with with inline scripts where the entire
@@ -367,7 +368,7 @@ export function createRenderState(
368 nonce === undefined
369 ? startInlineScript
370 : stringToPrecomputedChunk(
370 - '<script nonce="' + escapeTextForBrowser(nonce) + '">',
371 + '<script nonce="' + escapeTextForBrowser(nonce) + '"',
372 );
373 const idPrefix = resumableState.idPrefix;
374
@@ -376,8 +377,10 @@ export function createRenderState(
377 const {bootstrapScriptContent, bootstrapScripts, bootstrapModules} =
378 resumableState;
379 if (bootstrapScriptContent !== undefined) {
380 + bootstrapChunks.push(inlineScriptWithNonce);
381 + pushCompletedShellIdAttribute(bootstrapChunks, resumableState);
382 bootstrapChunks.push(
380 - inlineScriptWithNonce,
383 + endOfStartTag,
384 stringToChunk(escapeEntireInlineScriptContent(bootstrapScriptContent)),
385 endInlineScript,
386 );
@@ -527,25 +530,30 @@ export function createRenderState(
530 bootstrapChunks.push(
531 startScriptSrc,
532 stringToChunk(escapeTextForBrowser(src)),
533 + attributeEnd,
534 );
535 if (nonce) {
536 bootstrapChunks.push(
537 scriptNonce,
538 stringToChunk(escapeTextForBrowser(nonce)),
539 + attributeEnd,
540 );
541 }
542 if (typeof integrity === 'string') {
543 bootstrapChunks.push(
544 scriptIntegirty,
545 stringToChunk(escapeTextForBrowser(integrity)),
546 + attributeEnd,
547 );
548 }
549 if (typeof crossOrigin === 'string') {
550 bootstrapChunks.push(
551 scriptCrossOrigin,
552 stringToChunk(escapeTextForBrowser(crossOrigin)),
553 + attributeEnd,
554 );
555 }
556 + pushCompletedShellIdAttribute(bootstrapChunks, resumableState);
557 bootstrapChunks.push(endAsyncScript);
558 }
559 }
@@ -579,26 +587,30 @@ export function createRenderState(
587 bootstrapChunks.push(
588 startModuleSrc,
589 stringToChunk(escapeTextForBrowser(src)),
590 + attributeEnd,
591 );
583 -
592 if (nonce) {
593 bootstrapChunks.push(
594 scriptNonce,
595 stringToChunk(escapeTextForBrowser(nonce)),
596 + attributeEnd,
597 );
598 }
599 if (typeof integrity === 'string') {
600 bootstrapChunks.push(
601 scriptIntegirty,
602 stringToChunk(escapeTextForBrowser(integrity)),
603 + attributeEnd,
604 );
605 }
606 if (typeof crossOrigin === 'string') {
607 bootstrapChunks.push(
608 scriptCrossOrigin,
609 stringToChunk(escapeTextForBrowser(crossOrigin)),
610 + attributeEnd,
611 );
612 }
613 + pushCompletedShellIdAttribute(bootstrapChunks, resumableState);
614 bootstrapChunks.push(endAsyncScript);
615 }
616 }
@@ -1960,11 +1972,32 @@ function injectFormReplayingRuntime(
1972 (!enableFizzExternalRuntime || !renderState.externalRuntimeScript)
1973 ) {
1974 resumableState.instructions |= SentFormReplayingRuntime;
1963 - renderState.bootstrapChunks.unshift(
1964 - renderState.startInlineScript,
1965 - formReplayingRuntimeScript,
1966 - endInlineScript,
1967 - );
1975 + const preamble = renderState.preamble;
1976 + const bootstrapChunks = renderState.bootstrapChunks;
1977 + if (
1978 + (preamble.htmlChunks || preamble.headChunks) &&
1979 + bootstrapChunks.length === 0
1980 + ) {
1981 + // If we rendered the whole document, then we emitted a rel="expect" that needs a
1982 + // matching target. If we haven't emitted that yet, we need to include it in this
1983 + // script tag.
1984 + bootstrapChunks.push(renderState.startInlineScript);
1985 + pushCompletedShellIdAttribute(bootstrapChunks, resumableState);
1986 + bootstrapChunks.push(
1987 + endOfStartTag,
1988 + formReplayingRuntimeScript,
1989 + endInlineScript,
1990 + );
1991 + } else {
1992 + // Otherwise we added to the beginning of the scripts. This will mean that it
1993 + // appears before the shell ID unfortunately.
1994 + bootstrapChunks.unshift(
1995 + renderState.startInlineScript,
1996 + endOfStartTag,
1997 + formReplayingRuntimeScript,
1998 + endInlineScript,
1999 + );
2000 + }
2001 }
2002 }
2003
@@ -4075,8 +4108,21 @@ function writeBootstrap(
4108
4109 export function writeCompletedRoot(
4110 destination: Destination,
4111 + resumableState: ResumableState,
4112 renderState: RenderState,
4113 ): boolean {
4114 + const preamble = renderState.preamble;
4115 + if (preamble.htmlChunks || preamble.headChunks) {
4116 + // If we rendered the whole document, then we emitted a rel="expect" that needs a
4117 + // matching target. Normally we use one of the bootstrap scripts for this but if
4118 + // there are none, then we need to emit a tag to complete the shell.
4119 + if ((resumableState.instructions & SentCompletedShellId) === NothingSent) {
4120 + const bootstrapChunks = renderState.bootstrapChunks;
4121 + bootstrapChunks.push(startChunkForTag('template'));
4122 + pushCompletedShellIdAttribute(bootstrapChunks, resumableState);
4123 + bootstrapChunks.push(endOfStartTag, endChunkForTag('template'));
4124 + }
4125 + }
4126 return writeBootstrap(destination, renderState);
4127 }
4128
@@ -4400,6 +4446,7 @@ export function writeCompletedSegmentInstruction(
4446 resumableState.streamingFormat === ScriptStreamingFormat;
4447 if (scriptFormat) {
4448 writeChunk(destination, renderState.startInlineScript);
4449 + writeChunk(destination, endOfStartTag);
4450 if (
4451 (resumableState.instructions & SentCompleteSegmentFunction) ===
4452 NothingSent
@@ -4481,6 +4528,7 @@ export function writeCompletedBoundaryInstruction(
4528 resumableState.streamingFormat === ScriptStreamingFormat;
4529 if (scriptFormat) {
4530 writeChunk(destination, renderState.startInlineScript);
4531 + writeChunk(destination, endOfStartTag);
4532 if (requiresStyleInsertion) {
4533 if (
4534 (resumableState.instructions & SentCompleteBoundaryFunction) ===
@@ -4591,6 +4639,7 @@ export function writeClientRenderBoundaryInstruction(
4639 resumableState.streamingFormat === ScriptStreamingFormat;
4640 if (scriptFormat) {
4641 writeChunk(destination, renderState.startInlineScript);
4642 + writeChunk(destination, endOfStartTag);
4643 if (
4644 (resumableState.instructions & SentClientRenderFunction) ===
4645 NothingSent
@@ -4933,6 +4982,44 @@ function preloadLateStyles(this: Destination, styleQueue: StyleQueue) {
4982 styleQueue.sheets.clear();
4983 }
4984
4985 +const blockingRenderChunkStart = stringToPrecomputedChunk(
4986 + '<link rel="expect" href="#',
4987 +);
4988 +const blockingRenderChunkEnd = stringToPrecomputedChunk(
4989 + '" blocking="render"/>',
4990 +);
4991 +
4992 +function writeBlockingRenderInstruction(
4993 + destination: Destination,
4994 + resumableState: ResumableState,
4995 + renderState: RenderState,
4996 +): void {
4997 + const idPrefix = resumableState.idPrefix;
4998 + const shellId = '\u00AB' + idPrefix + 'R\u00BB';
4999 + writeChunk(destination, blockingRenderChunkStart);
5000 + writeChunk(destination, stringToChunk(escapeTextForBrowser(shellId)));
5001 + writeChunk(destination, blockingRenderChunkEnd);
5002 +}
5003 +
5004 +const completedShellIdAttributeStart = stringToPrecomputedChunk(' id="');
5005 +
5006 +function pushCompletedShellIdAttribute(
5007 + target: Array<Chunk | PrecomputedChunk>,
5008 + resumableState: ResumableState,
5009 +): void {
5010 + if ((resumableState.instructions & SentCompletedShellId) !== NothingSent) {
5011 + return;
5012 + }
5013 + resumableState.instructions |= SentCompletedShellId;
5014 + const idPrefix = resumableState.idPrefix;
5015 + const shellId = '\u00AB' + idPrefix + 'R\u00BB';
5016 + target.push(
5017 + completedShellIdAttributeStart,
5018 + stringToChunk(escapeTextForBrowser(shellId)),
5019 + attributeEnd,
5020 + );
5021 +}
5022 +
5023 // We don't bother reporting backpressure at the moment because we expect to
5024 // flush the entire preamble in a single pass. This probably should be modified
5025 // in the future to be backpressure sensitive but that requires a larger refactor
@@ -4942,6 +5029,7 @@ export function writePreambleStart(
5029 resumableState: ResumableState,
5030 renderState: RenderState,
5031 willFlushAllSegments: boolean,
5032 + skipExpect?: boolean, // Used as an override by ReactFizzConfigMarkup
5033 ): void {
5034 // This function must be called exactly once on every request
5035 if (
@@ -5027,6 +5115,16 @@ export function writePreambleStart(
5115 renderState.bulkPreloads.forEach(flushResource, destination);
5116 renderState.bulkPreloads.clear();
5117
5118 + if ((htmlChunks || headChunks) && !skipExpect) {
5119 + // If we have any html or head chunks we know that we're rendering a full document.
5120 + // A full document should block display until the full shell has downloaded.
5121 + // Therefore we insert a render blocking instruction referring to the last body
5122 + // element that's considered part of the shell. We do this after the important loads
5123 + // have already been emitted so we don't do anything to delay them but early so that
5124 + // the browser doesn't risk painting too early.
5125 + writeBlockingRenderInstruction(destination, resumableState, renderState);
5126 + }
5127 +
5128 // Write embedding hoistableChunks
5129 const hoistableChunks = renderState.hoistableChunks;
5130 for (i = 0; i < hoistableChunks.length; i++) {
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+6 -5
@@ -3580,7 +3580,8 @@ describe('ReactDOMFizzServer', () => {
3580 expect(document.head.innerHTML).toBe(
3581 '<script type="importmap">' +
3582 JSON.stringify(importMap) +
3583 - '</script><script async="" src="foo"></script>',
3583 + '</script><script async="" src="foo"></script>' +
3584 + '<link rel="expect" href="#«R»" blocking="render">',
3585 );
3586 });
3587
@@ -4189,7 +4190,7 @@ describe('ReactDOMFizzServer', () => {
4190 renderOptions.unstable_externalRuntimeSrc,
4191 ).map(n => n.outerHTML),
4192 ).toEqual([
4192 - '<script src="foo" async=""></script>',
4193 + '<script src="foo" id="«R»" async=""></script>',
4194 '<script src="bar" async=""></script>',
4195 '<script src="baz" integrity="qux" async=""></script>',
4196 '<script type="module" src="quux" async=""></script>',
@@ -4276,7 +4277,7 @@ describe('ReactDOMFizzServer', () => {
4277 renderOptions.unstable_externalRuntimeSrc,
4278 ).map(n => n.outerHTML),
4279 ).toEqual([
4279 - '<script src="foo" async=""></script>',
4280 + '<script src="foo" id="«R»" async=""></script>',
4281 '<script src="bar" async=""></script>',
4282 '<script src="baz" crossorigin="" async=""></script>',
4283 '<script src="qux" crossorigin="" async=""></script>',
@@ -4512,7 +4513,7 @@ describe('ReactDOMFizzServer', () => {
4513
4514 // the html should be as-is
4515 expect(document.documentElement.innerHTML).toEqual(
4515 - '<head></head><body><p>hello world!</p></body>',
4516 + '<head><link rel="expect" href="#«R»" blocking="render"></head><body><p>hello world!</p><template id="«R»"></template></body>',
4517 );
4518 });
4519
@@ -6492,7 +6493,7 @@ describe('ReactDOMFizzServer', () => {
6493 });
6494
6495 expect(document.documentElement.outerHTML).toEqual(
6495 - '<html><head></head><body><script>try { foo() } catch (e) {} ;</script></body></html>',
6496 + '<html><head><link rel="expect" href="#«R»" blocking="render"></head><body><script>try { foo() } catch (e) {} ;</script><template id="«R»"></template></body></html>',
6497 );
6498 });
6499
packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js
+4 -4
@@ -85,7 +85,7 @@ describe('ReactDOMFizzServerBrowser', () => {
85 );
86 const result = await readResult(stream);
87 expect(result).toMatchInlineSnapshot(
88 - `"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
88 + `"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body>hello world<template id="«R»"></template></body></html>"`,
89 );
90 });
91
@@ -99,7 +99,7 @@ describe('ReactDOMFizzServerBrowser', () => {
99 );
100 const result = await readResult(stream);
101 expect(result).toMatchInlineSnapshot(
102 - `"<link rel="preload" as="script" fetchPriority="low" href="init.js"/><link rel="modulepreload" fetchPriority="low" href="init.mjs"/><div>hello world</div><script>INIT();</script><script src="init.js" async=""></script><script type="module" src="init.mjs" async=""></script>"`,
102 + `"<link rel="preload" as="script" fetchPriority="low" href="init.js"/><link rel="modulepreload" fetchPriority="low" href="init.mjs"/><div>hello world</div><script id="«R»">INIT();</script><script src="init.js" async=""></script><script type="module" src="init.mjs" async=""></script>"`,
103 );
104 });
105
@@ -529,7 +529,7 @@ describe('ReactDOMFizzServerBrowser', () => {
529
530 const result = await readResult(stream);
531 expect(result).toEqual(
532 - '<!DOCTYPE html><html><head><title>foo</title></head><body>bar</body></html>',
532 + '<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/><title>foo</title></head><body>bar<template id="«R»"></template></body></html>',
533 );
534 });
535
@@ -547,7 +547,7 @@ describe('ReactDOMFizzServerBrowser', () => {
547 expect(result).toMatchInlineSnapshot(
548 // TODO: remove interpolation because it prevents snapshot updates.
549 // eslint-disable-next-line jest/no-interpolation-in-snapshots
550 - `"<link rel="preload" as="script" fetchPriority="low" nonce="R4nd0m" href="init.js"/><link rel="modulepreload" fetchPriority="low" nonce="R4nd0m" href="init.mjs"/><div>hello world</div><script nonce="${nonce}">INIT();</script><script src="init.js" nonce="${nonce}" async=""></script><script type="module" src="init.mjs" nonce="${nonce}" async=""></script>"`,
550 + `"<link rel="preload" as="script" fetchPriority="low" nonce="R4nd0m" href="init.js"/><link rel="modulepreload" fetchPriority="low" nonce="R4nd0m" href="init.mjs"/><div>hello world</div><script nonce="${nonce}" id="«R»">INIT();</script><script src="init.js" nonce="${nonce}" async=""></script><script type="module" src="init.mjs" nonce="${nonce}" async=""></script>"`,
551 );
552 });
553
packages/react-dom/src/__tests__/ReactDOMFizzServerEdge-test.js
+1 -1
@@ -72,7 +72,7 @@ describe('ReactDOMFizzServerEdge', () => {
72 });
73
74 expect(result).toMatchInlineSnapshot(
75 - `"<!DOCTYPE html><html><head></head><body><main>hello</main></body></html>"`,
75 + `"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body><main>hello</main><template id="«R»"></template></body></html>"`,
76 );
77 });
78 });
packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js
+2 -2
@@ -79,7 +79,7 @@ describe('ReactDOMFizzServerNode', () => {
79 });
80 // with Float, we emit empty heads if they are elided when rendering <html>
81 expect(output.result).toMatchInlineSnapshot(
82 - `"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
82 + `"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body>hello world<template id="«R»"></template></body></html>"`,
83 );
84 });
85
@@ -97,7 +97,7 @@ describe('ReactDOMFizzServerNode', () => {
97 pipe(writable);
98 });
99 expect(output.result).toMatchInlineSnapshot(
100 - `"<link rel="preload" as="script" fetchPriority="low" href="init.js"/><link rel="modulepreload" fetchPriority="low" href="init.mjs"/><div>hello world</div><script>INIT();</script><script src="init.js" async=""></script><script type="module" src="init.mjs" async=""></script>"`,
100 + `"<link rel="preload" as="script" fetchPriority="low" href="init.js"/><link rel="modulepreload" fetchPriority="low" href="init.mjs"/><div>hello world</div><script id="«R»">INIT();</script><script src="init.js" async=""></script><script type="module" src="init.mjs" async=""></script>"`,
101 );
102 });
103
packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js
+4 -1
@@ -106,7 +106,10 @@ describe('ReactDOMFizzStatic', () => {
106 node.tagName !== 'TEMPLATE' &&
107 node.tagName !== 'template' &&
108 !node.hasAttribute('hidden') &&
109 - !node.hasAttribute('aria-hidden')
109 + !node.hasAttribute('aria-hidden') &&
110 + // Ignore the render blocking expect
111 + (node.getAttribute('rel') !== 'expect' ||
112 + node.getAttribute('blocking') !== 'render')
113 ) {
114 const props = {};
115 const attributes = node.attributes;
packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js
+13 -7
@@ -187,7 +187,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
187 );
188 const prelude = await readContent(result.prelude);
189 expect(prelude).toMatchInlineSnapshot(
190 - `"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
190 + `"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body>hello world<template id="«R»"></template></body></html>"`,
191 );
192 });
193
@@ -201,7 +201,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
201 );
202 const prelude = await readContent(result.prelude);
203 expect(prelude).toMatchInlineSnapshot(
204 - `"<link rel="preload" as="script" fetchPriority="low" href="init.js"/><link rel="modulepreload" fetchPriority="low" href="init.mjs"/><div>hello world</div><script>INIT();</script><script src="init.js" async=""></script><script type="module" src="init.mjs" async=""></script>"`,
204 + `"<link rel="preload" as="script" fetchPriority="low" href="init.js"/><link rel="modulepreload" fetchPriority="low" href="init.mjs"/><div>hello world</div><script id="«R»">INIT();</script><script src="init.js" async=""></script><script type="module" src="init.mjs" async=""></script>"`,
205 );
206 });
207
@@ -1428,7 +1428,8 @@ describe('ReactDOMFizzStaticBrowser', () => {
1428 expect(await readContent(content)).toBe(
1429 '<!DOCTYPE html><html lang="en"><head>' +
1430 '<link rel="stylesheet" href="my-style" data-precedence="high"/>' +
1431 - '</head><body>Hello</body></html>',
1431 + '<link rel="expect" href="#«R»" blocking="render"/></head>' +
1432 + '<body>Hello<template id="«R»"></template></body></html>',
1433 );
1434 });
1435
@@ -1474,7 +1475,8 @@ describe('ReactDOMFizzStaticBrowser', () => {
1475 expect(await readContent(content)).toBe(
1476 '<!DOCTYPE html><html lang="en"><head>' +
1477 '<link rel="stylesheet" href="my-style" data-precedence="high"/>' +
1477 - '</head><body>Hello</body></html>',
1478 + '<link rel="expect" href="#«R»" blocking="render"/></head>' +
1479 + '<body>Hello<template id="«R»"></template></body></html>',
1480 );
1481 });
1482
@@ -1525,7 +1527,8 @@ describe('ReactDOMFizzStaticBrowser', () => {
1527 expect(await readContent(content)).toBe(
1528 '<!DOCTYPE html><html><head>' +
1529 '<link rel="stylesheet" href="my-style" data-precedence="high"/>' +
1528 - '</head><body><div>Hello</div></body></html>',
1530 + '<link rel="expect" href="#«R»" blocking="render"/></head>' +
1531 + '<body><div>Hello</div><template id="«R»"></template></body></html>',
1532 );
1533 });
1534
@@ -1607,7 +1610,8 @@ describe('ReactDOMFizzStaticBrowser', () => {
1610 let result = decoder.decode(value, {stream: true});
1611
1612 expect(result).toBe(
1610 - '<!DOCTYPE html><html><head></head><body>hello<!--$?--><template id="B:1"></template><!--/$-->',
1613 + '<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head>' +
1614 + '<body>hello<!--$?--><template id="B:1"></template><!--/$--><template id="«R»"></template>',
1615 );
1616
1617 await 1;
@@ -1631,7 +1635,9 @@ describe('ReactDOMFizzStaticBrowser', () => {
1635 const slice = result.slice(0, instructionIndex + '$RC'.length);
1636
1637 expect(slice).toBe(
1634 - '<!DOCTYPE html><html><head></head><body>hello<!--$?--><template id="B:1"></template><!--/$--><div hidden id="S:1">world<!-- --></div><script>$RC',
1638 + '<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head>' +
1639 + '<body>hello<!--$?--><template id="B:1"></template><!--/$--><template id="«R»"></template>' +
1640 + '<div hidden id="S:1">world<!-- --></div><script>$RC',
1641 );
1642 });
1643
packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js
+2 -2
@@ -64,7 +64,7 @@ describe('ReactDOMFizzStaticNode', () => {
64 );
65 const prelude = await readContent(result.prelude);
66 expect(prelude).toMatchInlineSnapshot(
67 - `"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
67 + `"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body>hello world<template id="«R»"></template></body></html>"`,
68 );
69 });
70
@@ -80,7 +80,7 @@ describe('ReactDOMFizzStaticNode', () => {
80 );
81 const prelude = await readContent(result.prelude);
82 expect(prelude).toMatchInlineSnapshot(
83 - `"<link rel="preload" as="script" fetchPriority="low" href="init.js"/><link rel="modulepreload" fetchPriority="low" href="init.mjs"/><div>hello world</div><script>INIT();</script><script src="init.js" async=""></script><script type="module" src="init.mjs" async=""></script>"`,
83 + `"<link rel="preload" as="script" fetchPriority="low" href="init.js"/><link rel="modulepreload" fetchPriority="low" href="init.mjs"/><div>hello world</div><script id="«R»">INIT();</script><script src="init.js" async=""></script><script type="module" src="init.mjs" async=""></script>"`,
84 );
85 });
86
packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+7 -2
@@ -250,7 +250,10 @@ describe('ReactDOMFloat', () => {
250 node.tagName !== 'TEMPLATE' &&
251 node.tagName !== 'template' &&
252 !node.hasAttribute('hidden') &&
253 - !node.hasAttribute('aria-hidden'))
253 + !node.hasAttribute('aria-hidden') &&
254 + // Ignore the render blocking expect
255 + (node.getAttribute('rel') !== 'expect' ||
256 + node.getAttribute('blocking') !== 'render'))
257 ) {
258 const props = {};
259 const attributes = node.attributes;
@@ -690,7 +693,9 @@ describe('ReactDOMFloat', () => {
693 pipe(writable);
694 });
695 expect(chunks).toEqual([
693 - '<!DOCTYPE html><html><head><script async="" src="foo"></script><title>foo</title></head><body>bar',
696 + '<!DOCTYPE html><html><head><script async="" src="foo"></script>' +
697 + '<link rel="expect" href="#«R»" blocking="render"/><title>foo</title></head>' +
698 + '<body>bar<template id="«R»"></template>',
699 '</body></html>',
700 ]);
701 });
packages/react-dom/src/__tests__/ReactDOMLegacyFloat-test.js
+2 -1
@@ -34,7 +34,8 @@ describe('ReactDOMFloat', () => {
34 );
35
36 expect(result).toEqual(
37 - '<html><head><meta charSet="utf-8"/><title>title</title><script src="foo"></script></head></html>',
37 + '<html><head><meta charSet="utf-8"/><link rel="expect" href="#«R»" blocking="render"/>' +
38 + '<title>title</title><script src="foo"></script></head><template id="«R»"></template></html>',
39 );
40 });
41 });
packages/react-dom/src/__tests__/ReactDOMSingletonComponents-test.js
+4 -1
@@ -104,7 +104,10 @@ describe('ReactDOM HostSingleton', () => {
104 el.tagName !== 'TEMPLATE' &&
105 el.tagName !== 'template' &&
106 !el.hasAttribute('hidden') &&
107 - !el.hasAttribute('aria-hidden')) ||
107 + !el.hasAttribute('aria-hidden') &&
108 + // Ignore the render blocking expect
109 + (node.getAttribute('rel') !== 'expect' ||
110 + node.getAttribute('blocking') !== 'render')) ||
111 el.hasAttribute('data-meaningful')
112 ) {
113 const props = {};
packages/react-dom/src/__tests__/ReactRenderDocument-test.js
+25 -9
@@ -77,12 +77,16 @@ describe('rendering React components at document', () => {
77 await act(() => {
78 root = ReactDOMClient.hydrateRoot(testDocument, <Root hello="world" />);
79 });
80 - expect(testDocument.body.innerHTML).toBe('Hello world');
80 + expect(testDocument.body.innerHTML).toBe(
81 + 'Hello world' + '<template id="«R»"></template>',
82 + );
83
84 await act(() => {
85 root.render(<Root hello="moon" />);
86 });
85 - expect(testDocument.body.innerHTML).toBe('Hello moon');
87 + expect(testDocument.body.innerHTML).toBe(
88 + 'Hello moon' + '<template id="«R»"></template>',
89 + );
90
91 expect(body === testDocument.body).toBe(true);
92 });
@@ -107,7 +111,9 @@ describe('rendering React components at document', () => {
111 await act(() => {
112 root = ReactDOMClient.hydrateRoot(testDocument, <Root />);
113 });
110 - expect(testDocument.body.innerHTML).toBe('Hello world');
114 + expect(testDocument.body.innerHTML).toBe(
115 + 'Hello world' + '<template id="«R»"></template>',
116 + );
117
118 const originalDocEl = testDocument.documentElement;
119 const originalHead = testDocument.head;
@@ -118,8 +124,10 @@ describe('rendering React components at document', () => {
124 expect(testDocument.firstChild).toBe(originalDocEl);
125 expect(testDocument.head).toBe(originalHead);
126 expect(testDocument.body).toBe(originalBody);
121 - expect(originalBody.firstChild).toEqual(null);
122 - expect(originalHead.firstChild).toEqual(null);
127 + expect(originalBody.innerHTML).toBe('<template id="«R»"></template>');
128 + expect(originalHead.innerHTML).toBe(
129 + '<link rel="expect" href="#«R»" blocking="render">',
130 + );
131 });
132
133 it('should not be able to switch root constructors', async () => {
@@ -157,13 +165,17 @@ describe('rendering React components at document', () => {
165 root = ReactDOMClient.hydrateRoot(testDocument, <Component />);
166 });
167
160 - expect(testDocument.body.innerHTML).toBe('Hello world');
168 + expect(testDocument.body.innerHTML).toBe(
169 + 'Hello world' + '<template id="«R»"></template>',
170 + );
171
172 await act(() => {
173 root.render(<Component2 />);
174 });
175
166 - expect(testDocument.body.innerHTML).toBe('Goodbye world');
176 + expect(testDocument.body.innerHTML).toBe(
177 + '<template id="«R»"></template>' + 'Goodbye world',
178 + );
179 });
180
181 it('should be able to mount into document', async () => {
@@ -192,7 +204,9 @@ describe('rendering React components at document', () => {
204 );
205 });
206
195 - expect(testDocument.body.innerHTML).toBe('Hello world');
207 + expect(testDocument.body.innerHTML).toBe(
208 + 'Hello world' + '<template id="«R»"></template>',
209 + );
210 });
211
212 it('cannot render over an existing text child at the root', async () => {
@@ -325,7 +339,9 @@ describe('rendering React components at document', () => {
339 : [],
340 );
341 expect(testDocument.body.innerHTML).toBe(
328 - favorSafetyOverHydrationPerf ? 'Hello world' : 'Goodbye world',
342 + favorSafetyOverHydrationPerf
343 + ? 'Hello world'
344 + : 'Goodbye world<template id="«R»"></template>',
345 );
346 });
347
packages/react-dom/src/test-utils/FizzTestUtils.js
+4 -1
@@ -150,7 +150,10 @@ function getVisibleChildren(element: Element): React$Node {
150 node.tagName !== 'TEMPLATE' &&
151 node.tagName !== 'template' &&
152 !node.hasAttribute('hidden') &&
153 - !node.hasAttribute('aria-hidden')
153 + !node.hasAttribute('aria-hidden') &&
154 + // Ignore the render blocking expect
155 + (node.getAttribute('rel') !== 'expect' ||
156 + node.getAttribute('blocking') !== 'render')
157 ) {
158 const props: any = {};
159 const attributes = node.attributes;
packages/react-markup/src/ReactFizzConfigMarkup.js
+29 -3
@@ -17,7 +17,10 @@ import type {
17 FormatContext,
18 } from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
19
20 -import {pushStartInstance as pushStartInstanceImpl} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
20 +import {
21 + pushStartInstance as pushStartInstanceImpl,
22 + writePreambleStart as writePreambleStartImpl,
23 +} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
24
25 import type {
26 Destination,
@@ -62,13 +65,11 @@ export {
65 writeEndPendingSuspenseBoundary,
66 writeHoistablesForBoundary,
67 writePlaceholder,
65 - writeCompletedRoot,
68 createRootFormatContext,
69 createRenderState,
70 createResumableState,
71 createPreambleState,
72 createHoistableState,
71 - writePreambleStart,
73 writePreambleEnd,
74 writeHoistables,
75 writePostamble,
@@ -203,5 +204,30 @@ export function writeEndClientRenderedSuspenseBoundary(
204 return true;
205 }
206
207 +export function writePreambleStart(
208 + destination: Destination,
209 + resumableState: ResumableState,
210 + renderState: RenderState,
211 + willFlushAllSegments: boolean,
212 + skipExpect?: boolean, // Used as an override by ReactFizzConfigMarkup
213 +): void {
214 + return writePreambleStartImpl(
215 + destination,
216 + resumableState,
217 + renderState,
218 + willFlushAllSegments,
219 + true, // skipExpect
220 + );
221 +}
222 +
223 +export function writeCompletedRoot(
224 + destination: Destination,
225 + resumableState: ResumableState,
226 + renderState: RenderState,
227 +): boolean {
228 + // Markup doesn't have any bootstrap scripts nor shell completions.
229 + return true;
230 +}
231 +
232 export type TransitionStatus = FormStatus;
233 export const NotPendingTransition: TransitionStatus = NotPending;
packages/react-server-dom-fb/src/__tests__/ReactDOMServerFB-test.internal.js
+1 -1
@@ -59,7 +59,7 @@ describe('ReactDOMServerFB', () => {
59 });
60 const result = readResult(stream);
61 expect(result).toMatchInlineSnapshot(
62 - `"<link rel="preload" as="script" fetchPriority="low" href="init.js"/><link rel="modulepreload" fetchPriority="low" href="init.mjs"/><div>hello world</div><script>INIT();</script><script src="init.js" async=""></script><script type="module" src="init.mjs" async=""></script>"`,
62 + `"<link rel="preload" as="script" fetchPriority="low" href="init.js"/><link rel="modulepreload" fetchPriority="low" href="init.mjs"/><div>hello world</div><script id="«R»">INIT();</script><script src="init.js" async=""></script><script type="module" src="init.mjs" async=""></script>"`,
63 );
64 });
65
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js
+10 -3
@@ -193,7 +193,10 @@ describe('ReactFlightDOM', () => {
193 node.tagName !== 'TEMPLATE' &&
194 node.tagName !== 'template' &&
195 !node.hasAttribute('hidden') &&
196 - !node.hasAttribute('aria-hidden'))
196 + !node.hasAttribute('aria-hidden') &&
197 + // Ignore the render blocking expect
198 + (node.getAttribute('rel') !== 'expect' ||
199 + node.getAttribute('blocking') !== 'render'))
200 ) {
201 const props = {};
202 const attributes = node.attributes;
@@ -1917,11 +1920,15 @@ describe('ReactFlightDOM', () => {
1920
1921 expect(content1).toEqual(
1922 '<!DOCTYPE html><html><head><link rel="preload" href="before1" as="style"/>' +
1920 - '<link rel="preload" href="after1" as="style"/></head><body><p>hello world</p></body></html>',
1923 + '<link rel="preload" href="after1" as="style"/>' +
1924 + '<link rel="expect" href="#«R»" blocking="render"/></head>' +
1925 + '<body><p>hello world</p><template id="«R»"></template></body></html>',
1926 );
1927 expect(content2).toEqual(
1928 '<!DOCTYPE html><html><head><link rel="preload" href="before2" as="style"/>' +
1924 - '<link rel="preload" href="after2" as="style"/></head><body><p>hello world</p></body></html>',
1929 + '<link rel="preload" href="after2" as="style"/>' +
1930 + '<link rel="expect" href="#«R»" blocking="render"/></head>' +
1931 + '<body><p>hello world</p><template id="«R»"></template></body></html>',
1932 );
1933 });
1934
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js
+2 -2
@@ -1899,8 +1899,8 @@ describe('ReactFlightDOMBrowser', () => {
1899 }
1900
1901 expect(content).toEqual(
1902 - '<!DOCTYPE html><html><head>' +
1903 - '</head><body><p>hello world</p></body></html>',
1902 + '<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head>' +
1903 + '<body><p>hello world</p><template id="«R»"></template></body></html>',
1904 );
1905 });
1906
packages/react-server/src/ReactFizzServer.js
+5 -1
@@ -5157,7 +5157,11 @@ function flushCompletedQueues(
5157 );
5158 flushSegment(request, destination, completedRootSegment, null);
5159 request.completedRootSegment = null;
5160 - writeCompletedRoot(destination, request.renderState);
5160 + writeCompletedRoot(
5161 + destination,
5162 + request.resumableState,
5163 + request.renderState,
5164 + );
5165 }
5166
5167 writeHoistables(destination, request.resumableState, request.renderState);