[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.
This commit is contained in:
Sebastian Markbåge 2025-04-25 11:52:28 -04:00 committed by GitHub
parent 693803a9bb
commit 143d3e1b89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 274 additions and 66 deletions

View File

@ -1,5 +1,6 @@
import React from 'react';
import {renderToPipeableStream} from 'react-dom/server';
import {Writable} from 'stream';
import App from '../src/components/App';
@ -14,11 +15,41 @@ if (process.env.NODE_ENV === 'development') {
assets = require('../build/asset-manifest.json');
}
class ThrottledWritable extends Writable {
constructor(destination) {
super();
this.destination = destination;
this.delay = 150;
}
_write(chunk, encoding, callback) {
let o = 0;
const write = () => {
this.destination.write(chunk.slice(o, o + 100), encoding, x => {
o += 100;
if (o < chunk.length) {
setTimeout(write, this.delay);
} else {
callback(x);
}
});
};
setTimeout(write, this.delay);
}
_final(callback) {
setTimeout(() => {
this.destination.end(callback);
}, this.delay);
}
}
export default function render(url, res) {
res.socket.on('error', error => {
// Log fatal errors
console.error('Fatal', error);
});
console.log('hello');
let didError = false;
const {pipe, abort} = renderToPipeableStream(<App assets={assets} />, {
bootstrapScripts: [assets['main.js']],
@ -26,7 +57,10 @@ export default function render(url, res) {
// If something errored before we started streaming, we set the error code appropriately.
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-type', 'text/html');
pipe(res);
// To test the actual chunks taking time to load over the network, we throttle
// the stream a bit.
const throttledResponse = new ThrottledWritable(res);
pipe(throttledResponse);
},
onShellError(x) {
// Something errored before we could complete the shell so we emit an alternative shell.

View File

@ -37,6 +37,7 @@ export default class Chrome extends Component {
</div>
</Theme.Provider>
</Suspense>
<p>This should appear in the first paint.</p>
<script
dangerouslySetInnerHTML={{
__html: `assetManifest = ${JSON.stringify(assets)};`,

View File

@ -120,12 +120,13 @@ const ScriptStreamingFormat: StreamingFormat = 0;
const DataStreamingFormat: StreamingFormat = 1;
export type InstructionState = number;
const NothingSent /* */ = 0b00000;
const SentCompleteSegmentFunction /* */ = 0b00001;
const SentCompleteBoundaryFunction /* */ = 0b00010;
const SentClientRenderFunction /* */ = 0b00100;
const SentStyleInsertionFunction /* */ = 0b01000;
const SentFormReplayingRuntime /* */ = 0b10000;
const NothingSent /* */ = 0b000000;
const SentCompleteSegmentFunction /* */ = 0b000001;
const SentCompleteBoundaryFunction /* */ = 0b000010;
const SentClientRenderFunction /* */ = 0b000100;
const SentStyleInsertionFunction /* */ = 0b001000;
const SentFormReplayingRuntime /* */ = 0b010000;
const SentCompletedShellId /* */ = 0b100000;
// Per request, global state that is not contextual to the rendering subtree.
// This cannot be resumed and therefore should only contain things that are
@ -289,15 +290,15 @@ export type ResumableState = {
const dataElementQuotedEnd = stringToPrecomputedChunk('"></template>');
const startInlineScript = stringToPrecomputedChunk('<script>');
const startInlineScript = stringToPrecomputedChunk('<script');
const endInlineScript = stringToPrecomputedChunk('</script>');
const startScriptSrc = stringToPrecomputedChunk('<script src="');
const startModuleSrc = stringToPrecomputedChunk('<script type="module" src="');
const scriptNonce = stringToPrecomputedChunk('" nonce="');
const scriptIntegirty = stringToPrecomputedChunk('" integrity="');
const scriptCrossOrigin = stringToPrecomputedChunk('" crossorigin="');
const endAsyncScript = stringToPrecomputedChunk('" async=""></script>');
const scriptNonce = stringToPrecomputedChunk(' nonce="');
const scriptIntegirty = stringToPrecomputedChunk(' integrity="');
const scriptCrossOrigin = stringToPrecomputedChunk(' crossorigin="');
const endAsyncScript = stringToPrecomputedChunk(' async=""></script>');
/**
* This escaping function is designed to work with with inline scripts where the entire
@ -367,7 +368,7 @@ export function createRenderState(
nonce === undefined
? startInlineScript
: stringToPrecomputedChunk(
'<script nonce="' + escapeTextForBrowser(nonce) + '">',
'<script nonce="' + escapeTextForBrowser(nonce) + '"',
);
const idPrefix = resumableState.idPrefix;
@ -376,8 +377,10 @@ export function createRenderState(
const {bootstrapScriptContent, bootstrapScripts, bootstrapModules} =
resumableState;
if (bootstrapScriptContent !== undefined) {
bootstrapChunks.push(inlineScriptWithNonce);
pushCompletedShellIdAttribute(bootstrapChunks, resumableState);
bootstrapChunks.push(
inlineScriptWithNonce,
endOfStartTag,
stringToChunk(escapeEntireInlineScriptContent(bootstrapScriptContent)),
endInlineScript,
);
@ -527,25 +530,30 @@ export function createRenderState(
bootstrapChunks.push(
startScriptSrc,
stringToChunk(escapeTextForBrowser(src)),
attributeEnd,
);
if (nonce) {
bootstrapChunks.push(
scriptNonce,
stringToChunk(escapeTextForBrowser(nonce)),
attributeEnd,
);
}
if (typeof integrity === 'string') {
bootstrapChunks.push(
scriptIntegirty,
stringToChunk(escapeTextForBrowser(integrity)),
attributeEnd,
);
}
if (typeof crossOrigin === 'string') {
bootstrapChunks.push(
scriptCrossOrigin,
stringToChunk(escapeTextForBrowser(crossOrigin)),
attributeEnd,
);
}
pushCompletedShellIdAttribute(bootstrapChunks, resumableState);
bootstrapChunks.push(endAsyncScript);
}
}
@ -579,26 +587,30 @@ export function createRenderState(
bootstrapChunks.push(
startModuleSrc,
stringToChunk(escapeTextForBrowser(src)),
attributeEnd,
);
if (nonce) {
bootstrapChunks.push(
scriptNonce,
stringToChunk(escapeTextForBrowser(nonce)),
attributeEnd,
);
}
if (typeof integrity === 'string') {
bootstrapChunks.push(
scriptIntegirty,
stringToChunk(escapeTextForBrowser(integrity)),
attributeEnd,
);
}
if (typeof crossOrigin === 'string') {
bootstrapChunks.push(
scriptCrossOrigin,
stringToChunk(escapeTextForBrowser(crossOrigin)),
attributeEnd,
);
}
pushCompletedShellIdAttribute(bootstrapChunks, resumableState);
bootstrapChunks.push(endAsyncScript);
}
}
@ -1960,11 +1972,32 @@ function injectFormReplayingRuntime(
(!enableFizzExternalRuntime || !renderState.externalRuntimeScript)
) {
resumableState.instructions |= SentFormReplayingRuntime;
renderState.bootstrapChunks.unshift(
renderState.startInlineScript,
formReplayingRuntimeScript,
endInlineScript,
);
const preamble = renderState.preamble;
const bootstrapChunks = renderState.bootstrapChunks;
if (
(preamble.htmlChunks || preamble.headChunks) &&
bootstrapChunks.length === 0
) {
// If we rendered the whole document, then we emitted a rel="expect" that needs a
// matching target. If we haven't emitted that yet, we need to include it in this
// script tag.
bootstrapChunks.push(renderState.startInlineScript);
pushCompletedShellIdAttribute(bootstrapChunks, resumableState);
bootstrapChunks.push(
endOfStartTag,
formReplayingRuntimeScript,
endInlineScript,
);
} else {
// Otherwise we added to the beginning of the scripts. This will mean that it
// appears before the shell ID unfortunately.
bootstrapChunks.unshift(
renderState.startInlineScript,
endOfStartTag,
formReplayingRuntimeScript,
endInlineScript,
);
}
}
}
@ -4075,8 +4108,21 @@ function writeBootstrap(
export function writeCompletedRoot(
destination: Destination,
resumableState: ResumableState,
renderState: RenderState,
): boolean {
const preamble = renderState.preamble;
if (preamble.htmlChunks || preamble.headChunks) {
// If we rendered the whole document, then we emitted a rel="expect" that needs a
// matching target. Normally we use one of the bootstrap scripts for this but if
// there are none, then we need to emit a tag to complete the shell.
if ((resumableState.instructions & SentCompletedShellId) === NothingSent) {
const bootstrapChunks = renderState.bootstrapChunks;
bootstrapChunks.push(startChunkForTag('template'));
pushCompletedShellIdAttribute(bootstrapChunks, resumableState);
bootstrapChunks.push(endOfStartTag, endChunkForTag('template'));
}
}
return writeBootstrap(destination, renderState);
}
@ -4400,6 +4446,7 @@ export function writeCompletedSegmentInstruction(
resumableState.streamingFormat === ScriptStreamingFormat;
if (scriptFormat) {
writeChunk(destination, renderState.startInlineScript);
writeChunk(destination, endOfStartTag);
if (
(resumableState.instructions & SentCompleteSegmentFunction) ===
NothingSent
@ -4481,6 +4528,7 @@ export function writeCompletedBoundaryInstruction(
resumableState.streamingFormat === ScriptStreamingFormat;
if (scriptFormat) {
writeChunk(destination, renderState.startInlineScript);
writeChunk(destination, endOfStartTag);
if (requiresStyleInsertion) {
if (
(resumableState.instructions & SentCompleteBoundaryFunction) ===
@ -4591,6 +4639,7 @@ export function writeClientRenderBoundaryInstruction(
resumableState.streamingFormat === ScriptStreamingFormat;
if (scriptFormat) {
writeChunk(destination, renderState.startInlineScript);
writeChunk(destination, endOfStartTag);
if (
(resumableState.instructions & SentClientRenderFunction) ===
NothingSent
@ -4933,6 +4982,44 @@ function preloadLateStyles(this: Destination, styleQueue: StyleQueue) {
styleQueue.sheets.clear();
}
const blockingRenderChunkStart = stringToPrecomputedChunk(
'<link rel="expect" href="#',
);
const blockingRenderChunkEnd = stringToPrecomputedChunk(
'" blocking="render"/>',
);
function writeBlockingRenderInstruction(
destination: Destination,
resumableState: ResumableState,
renderState: RenderState,
): void {
const idPrefix = resumableState.idPrefix;
const shellId = '\u00AB' + idPrefix + 'R\u00BB';
writeChunk(destination, blockingRenderChunkStart);
writeChunk(destination, stringToChunk(escapeTextForBrowser(shellId)));
writeChunk(destination, blockingRenderChunkEnd);
}
const completedShellIdAttributeStart = stringToPrecomputedChunk(' id="');
function pushCompletedShellIdAttribute(
target: Array<Chunk | PrecomputedChunk>,
resumableState: ResumableState,
): void {
if ((resumableState.instructions & SentCompletedShellId) !== NothingSent) {
return;
}
resumableState.instructions |= SentCompletedShellId;
const idPrefix = resumableState.idPrefix;
const shellId = '\u00AB' + idPrefix + 'R\u00BB';
target.push(
completedShellIdAttributeStart,
stringToChunk(escapeTextForBrowser(shellId)),
attributeEnd,
);
}
// We don't bother reporting backpressure at the moment because we expect to
// flush the entire preamble in a single pass. This probably should be modified
// in the future to be backpressure sensitive but that requires a larger refactor
@ -4942,6 +5029,7 @@ export function writePreambleStart(
resumableState: ResumableState,
renderState: RenderState,
willFlushAllSegments: boolean,
skipExpect?: boolean, // Used as an override by ReactFizzConfigMarkup
): void {
// This function must be called exactly once on every request
if (
@ -5027,6 +5115,16 @@ export function writePreambleStart(
renderState.bulkPreloads.forEach(flushResource, destination);
renderState.bulkPreloads.clear();
if ((htmlChunks || headChunks) && !skipExpect) {
// If we have any html or head chunks we know that we're rendering a full document.
// A full document should block display until the full shell has downloaded.
// Therefore we insert a render blocking instruction referring to the last body
// element that's considered part of the shell. We do this after the important loads
// have already been emitted so we don't do anything to delay them but early so that
// the browser doesn't risk painting too early.
writeBlockingRenderInstruction(destination, resumableState, renderState);
}
// Write embedding hoistableChunks
const hoistableChunks = renderState.hoistableChunks;
for (i = 0; i < hoistableChunks.length; i++) {

View File

@ -3580,7 +3580,8 @@ describe('ReactDOMFizzServer', () => {
expect(document.head.innerHTML).toBe(
'<script type="importmap">' +
JSON.stringify(importMap) +
'</script><script async="" src="foo"></script>',
'</script><script async="" src="foo"></script>' +
'<link rel="expect" href="#«R»" blocking="render">',
);
});
@ -4189,7 +4190,7 @@ describe('ReactDOMFizzServer', () => {
renderOptions.unstable_externalRuntimeSrc,
).map(n => n.outerHTML),
).toEqual([
'<script src="foo" async=""></script>',
'<script src="foo" id="«R»" async=""></script>',
'<script src="bar" async=""></script>',
'<script src="baz" integrity="qux" async=""></script>',
'<script type="module" src="quux" async=""></script>',
@ -4276,7 +4277,7 @@ describe('ReactDOMFizzServer', () => {
renderOptions.unstable_externalRuntimeSrc,
).map(n => n.outerHTML),
).toEqual([
'<script src="foo" async=""></script>',
'<script src="foo" id="«R»" async=""></script>',
'<script src="bar" async=""></script>',
'<script src="baz" crossorigin="" async=""></script>',
'<script src="qux" crossorigin="" async=""></script>',
@ -4512,7 +4513,7 @@ describe('ReactDOMFizzServer', () => {
// the html should be as-is
expect(document.documentElement.innerHTML).toEqual(
'<head></head><body><p>hello world!</p></body>',
'<head><link rel="expect" href="#«R»" blocking="render"></head><body><p>hello world!</p><template id="«R»"></template></body>',
);
});
@ -6492,7 +6493,7 @@ describe('ReactDOMFizzServer', () => {
});
expect(document.documentElement.outerHTML).toEqual(
'<html><head></head><body><script>try { foo() } catch (e) {} ;</script></body></html>',
'<html><head><link rel="expect" href="#«R»" blocking="render"></head><body><script>try { foo() } catch (e) {} ;</script><template id="«R»"></template></body></html>',
);
});

View File

@ -85,7 +85,7 @@ describe('ReactDOMFizzServerBrowser', () => {
);
const result = await readResult(stream);
expect(result).toMatchInlineSnapshot(
`"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
`"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body>hello world<template id="«R»"></template></body></html>"`,
);
});
@ -99,7 +99,7 @@ describe('ReactDOMFizzServerBrowser', () => {
);
const result = await readResult(stream);
expect(result).toMatchInlineSnapshot(
`"<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>"`,
`"<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>"`,
);
});
@ -529,7 +529,7 @@ describe('ReactDOMFizzServerBrowser', () => {
const result = await readResult(stream);
expect(result).toEqual(
'<!DOCTYPE html><html><head><title>foo</title></head><body>bar</body></html>',
'<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/><title>foo</title></head><body>bar<template id="«R»"></template></body></html>',
);
});
@ -547,7 +547,7 @@ describe('ReactDOMFizzServerBrowser', () => {
expect(result).toMatchInlineSnapshot(
// TODO: remove interpolation because it prevents snapshot updates.
// eslint-disable-next-line jest/no-interpolation-in-snapshots
`"<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>"`,
`"<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>"`,
);
});

View File

@ -72,7 +72,7 @@ describe('ReactDOMFizzServerEdge', () => {
});
expect(result).toMatchInlineSnapshot(
`"<!DOCTYPE html><html><head></head><body><main>hello</main></body></html>"`,
`"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body><main>hello</main><template id="«R»"></template></body></html>"`,
);
});
});

View File

@ -79,7 +79,7 @@ describe('ReactDOMFizzServerNode', () => {
});
// with Float, we emit empty heads if they are elided when rendering <html>
expect(output.result).toMatchInlineSnapshot(
`"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
`"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body>hello world<template id="«R»"></template></body></html>"`,
);
});
@ -97,7 +97,7 @@ describe('ReactDOMFizzServerNode', () => {
pipe(writable);
});
expect(output.result).toMatchInlineSnapshot(
`"<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>"`,
`"<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>"`,
);
});

View File

@ -106,7 +106,10 @@ describe('ReactDOMFizzStatic', () => {
node.tagName !== 'TEMPLATE' &&
node.tagName !== 'template' &&
!node.hasAttribute('hidden') &&
!node.hasAttribute('aria-hidden')
!node.hasAttribute('aria-hidden') &&
// Ignore the render blocking expect
(node.getAttribute('rel') !== 'expect' ||
node.getAttribute('blocking') !== 'render')
) {
const props = {};
const attributes = node.attributes;

View File

@ -187,7 +187,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
);
const prelude = await readContent(result.prelude);
expect(prelude).toMatchInlineSnapshot(
`"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
`"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body>hello world<template id="«R»"></template></body></html>"`,
);
});
@ -201,7 +201,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
);
const prelude = await readContent(result.prelude);
expect(prelude).toMatchInlineSnapshot(
`"<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>"`,
`"<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>"`,
);
});
@ -1428,7 +1428,8 @@ describe('ReactDOMFizzStaticBrowser', () => {
expect(await readContent(content)).toBe(
'<!DOCTYPE html><html lang="en"><head>' +
'<link rel="stylesheet" href="my-style" data-precedence="high"/>' +
'</head><body>Hello</body></html>',
'<link rel="expect" href="#«R»" blocking="render"/></head>' +
'<body>Hello<template id="«R»"></template></body></html>',
);
});
@ -1474,7 +1475,8 @@ describe('ReactDOMFizzStaticBrowser', () => {
expect(await readContent(content)).toBe(
'<!DOCTYPE html><html lang="en"><head>' +
'<link rel="stylesheet" href="my-style" data-precedence="high"/>' +
'</head><body>Hello</body></html>',
'<link rel="expect" href="#«R»" blocking="render"/></head>' +
'<body>Hello<template id="«R»"></template></body></html>',
);
});
@ -1525,7 +1527,8 @@ describe('ReactDOMFizzStaticBrowser', () => {
expect(await readContent(content)).toBe(
'<!DOCTYPE html><html><head>' +
'<link rel="stylesheet" href="my-style" data-precedence="high"/>' +
'</head><body><div>Hello</div></body></html>',
'<link rel="expect" href="#«R»" blocking="render"/></head>' +
'<body><div>Hello</div><template id="«R»"></template></body></html>',
);
});
@ -1607,7 +1610,8 @@ describe('ReactDOMFizzStaticBrowser', () => {
let result = decoder.decode(value, {stream: true});
expect(result).toBe(
'<!DOCTYPE html><html><head></head><body>hello<!--$?--><template id="B:1"></template><!--/$-->',
'<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head>' +
'<body>hello<!--$?--><template id="B:1"></template><!--/$--><template id="«R»"></template>',
);
await 1;
@ -1631,7 +1635,9 @@ describe('ReactDOMFizzStaticBrowser', () => {
const slice = result.slice(0, instructionIndex + '$RC'.length);
expect(slice).toBe(
'<!DOCTYPE html><html><head></head><body>hello<!--$?--><template id="B:1"></template><!--/$--><div hidden id="S:1">world<!-- --></div><script>$RC',
'<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head>' +
'<body>hello<!--$?--><template id="B:1"></template><!--/$--><template id="«R»"></template>' +
'<div hidden id="S:1">world<!-- --></div><script>$RC',
);
});

View File

@ -64,7 +64,7 @@ describe('ReactDOMFizzStaticNode', () => {
);
const prelude = await readContent(result.prelude);
expect(prelude).toMatchInlineSnapshot(
`"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
`"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body>hello world<template id="«R»"></template></body></html>"`,
);
});
@ -80,7 +80,7 @@ describe('ReactDOMFizzStaticNode', () => {
);
const prelude = await readContent(result.prelude);
expect(prelude).toMatchInlineSnapshot(
`"<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>"`,
`"<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>"`,
);
});

View File

@ -250,7 +250,10 @@ describe('ReactDOMFloat', () => {
node.tagName !== 'TEMPLATE' &&
node.tagName !== 'template' &&
!node.hasAttribute('hidden') &&
!node.hasAttribute('aria-hidden'))
!node.hasAttribute('aria-hidden') &&
// Ignore the render blocking expect
(node.getAttribute('rel') !== 'expect' ||
node.getAttribute('blocking') !== 'render'))
) {
const props = {};
const attributes = node.attributes;
@ -690,7 +693,9 @@ describe('ReactDOMFloat', () => {
pipe(writable);
});
expect(chunks).toEqual([
'<!DOCTYPE html><html><head><script async="" src="foo"></script><title>foo</title></head><body>bar',
'<!DOCTYPE html><html><head><script async="" src="foo"></script>' +
'<link rel="expect" href="#«R»" blocking="render"/><title>foo</title></head>' +
'<body>bar<template id="«R»"></template>',
'</body></html>',
]);
});

View File

@ -34,7 +34,8 @@ describe('ReactDOMFloat', () => {
);
expect(result).toEqual(
'<html><head><meta charSet="utf-8"/><title>title</title><script src="foo"></script></head></html>',
'<html><head><meta charSet="utf-8"/><link rel="expect" href="#«R»" blocking="render"/>' +
'<title>title</title><script src="foo"></script></head><template id="«R»"></template></html>',
);
});
});

View File

@ -104,7 +104,10 @@ describe('ReactDOM HostSingleton', () => {
el.tagName !== 'TEMPLATE' &&
el.tagName !== 'template' &&
!el.hasAttribute('hidden') &&
!el.hasAttribute('aria-hidden')) ||
!el.hasAttribute('aria-hidden') &&
// Ignore the render blocking expect
(node.getAttribute('rel') !== 'expect' ||
node.getAttribute('blocking') !== 'render')) ||
el.hasAttribute('data-meaningful')
) {
const props = {};

View File

@ -77,12 +77,16 @@ describe('rendering React components at document', () => {
await act(() => {
root = ReactDOMClient.hydrateRoot(testDocument, <Root hello="world" />);
});
expect(testDocument.body.innerHTML).toBe('Hello world');
expect(testDocument.body.innerHTML).toBe(
'Hello world' + '<template id="«R»"></template>',
);
await act(() => {
root.render(<Root hello="moon" />);
});
expect(testDocument.body.innerHTML).toBe('Hello moon');
expect(testDocument.body.innerHTML).toBe(
'Hello moon' + '<template id="«R»"></template>',
);
expect(body === testDocument.body).toBe(true);
});
@ -107,7 +111,9 @@ describe('rendering React components at document', () => {
await act(() => {
root = ReactDOMClient.hydrateRoot(testDocument, <Root />);
});
expect(testDocument.body.innerHTML).toBe('Hello world');
expect(testDocument.body.innerHTML).toBe(
'Hello world' + '<template id="«R»"></template>',
);
const originalDocEl = testDocument.documentElement;
const originalHead = testDocument.head;
@ -118,8 +124,10 @@ describe('rendering React components at document', () => {
expect(testDocument.firstChild).toBe(originalDocEl);
expect(testDocument.head).toBe(originalHead);
expect(testDocument.body).toBe(originalBody);
expect(originalBody.firstChild).toEqual(null);
expect(originalHead.firstChild).toEqual(null);
expect(originalBody.innerHTML).toBe('<template id="«R»"></template>');
expect(originalHead.innerHTML).toBe(
'<link rel="expect" href="#«R»" blocking="render">',
);
});
it('should not be able to switch root constructors', async () => {
@ -157,13 +165,17 @@ describe('rendering React components at document', () => {
root = ReactDOMClient.hydrateRoot(testDocument, <Component />);
});
expect(testDocument.body.innerHTML).toBe('Hello world');
expect(testDocument.body.innerHTML).toBe(
'Hello world' + '<template id="«R»"></template>',
);
await act(() => {
root.render(<Component2 />);
});
expect(testDocument.body.innerHTML).toBe('Goodbye world');
expect(testDocument.body.innerHTML).toBe(
'<template id="«R»"></template>' + 'Goodbye world',
);
});
it('should be able to mount into document', async () => {
@ -192,7 +204,9 @@ describe('rendering React components at document', () => {
);
});
expect(testDocument.body.innerHTML).toBe('Hello world');
expect(testDocument.body.innerHTML).toBe(
'Hello world' + '<template id="«R»"></template>',
);
});
it('cannot render over an existing text child at the root', async () => {
@ -325,7 +339,9 @@ describe('rendering React components at document', () => {
: [],
);
expect(testDocument.body.innerHTML).toBe(
favorSafetyOverHydrationPerf ? 'Hello world' : 'Goodbye world',
favorSafetyOverHydrationPerf
? 'Hello world'
: 'Goodbye world<template id="«R»"></template>',
);
});

View File

@ -150,7 +150,10 @@ function getVisibleChildren(element: Element): React$Node {
node.tagName !== 'TEMPLATE' &&
node.tagName !== 'template' &&
!node.hasAttribute('hidden') &&
!node.hasAttribute('aria-hidden')
!node.hasAttribute('aria-hidden') &&
// Ignore the render blocking expect
(node.getAttribute('rel') !== 'expect' ||
node.getAttribute('blocking') !== 'render')
) {
const props: any = {};
const attributes = node.attributes;

View File

@ -17,7 +17,10 @@ import type {
FormatContext,
} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
import {pushStartInstance as pushStartInstanceImpl} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
import {
pushStartInstance as pushStartInstanceImpl,
writePreambleStart as writePreambleStartImpl,
} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
import type {
Destination,
@ -62,13 +65,11 @@ export {
writeEndPendingSuspenseBoundary,
writeHoistablesForBoundary,
writePlaceholder,
writeCompletedRoot,
createRootFormatContext,
createRenderState,
createResumableState,
createPreambleState,
createHoistableState,
writePreambleStart,
writePreambleEnd,
writeHoistables,
writePostamble,
@ -203,5 +204,30 @@ export function writeEndClientRenderedSuspenseBoundary(
return true;
}
export function writePreambleStart(
destination: Destination,
resumableState: ResumableState,
renderState: RenderState,
willFlushAllSegments: boolean,
skipExpect?: boolean, // Used as an override by ReactFizzConfigMarkup
): void {
return writePreambleStartImpl(
destination,
resumableState,
renderState,
willFlushAllSegments,
true, // skipExpect
);
}
export function writeCompletedRoot(
destination: Destination,
resumableState: ResumableState,
renderState: RenderState,
): boolean {
// Markup doesn't have any bootstrap scripts nor shell completions.
return true;
}
export type TransitionStatus = FormStatus;
export const NotPendingTransition: TransitionStatus = NotPending;

View File

@ -59,7 +59,7 @@ describe('ReactDOMServerFB', () => {
});
const result = readResult(stream);
expect(result).toMatchInlineSnapshot(
`"<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>"`,
`"<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>"`,
);
});

View File

@ -193,7 +193,10 @@ describe('ReactFlightDOM', () => {
node.tagName !== 'TEMPLATE' &&
node.tagName !== 'template' &&
!node.hasAttribute('hidden') &&
!node.hasAttribute('aria-hidden'))
!node.hasAttribute('aria-hidden') &&
// Ignore the render blocking expect
(node.getAttribute('rel') !== 'expect' ||
node.getAttribute('blocking') !== 'render'))
) {
const props = {};
const attributes = node.attributes;
@ -1917,11 +1920,15 @@ describe('ReactFlightDOM', () => {
expect(content1).toEqual(
'<!DOCTYPE html><html><head><link rel="preload" href="before1" as="style"/>' +
'<link rel="preload" href="after1" as="style"/></head><body><p>hello world</p></body></html>',
'<link rel="preload" href="after1" as="style"/>' +
'<link rel="expect" href="#«R»" blocking="render"/></head>' +
'<body><p>hello world</p><template id="«R»"></template></body></html>',
);
expect(content2).toEqual(
'<!DOCTYPE html><html><head><link rel="preload" href="before2" as="style"/>' +
'<link rel="preload" href="after2" as="style"/></head><body><p>hello world</p></body></html>',
'<link rel="preload" href="after2" as="style"/>' +
'<link rel="expect" href="#«R»" blocking="render"/></head>' +
'<body><p>hello world</p><template id="«R»"></template></body></html>',
);
});

View File

@ -1899,8 +1899,8 @@ describe('ReactFlightDOMBrowser', () => {
}
expect(content).toEqual(
'<!DOCTYPE html><html><head>' +
'</head><body><p>hello world</p></body></html>',
'<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head>' +
'<body><p>hello world</p><template id="«R»"></template></body></html>',
);
});

View File

@ -5157,7 +5157,11 @@ function flushCompletedQueues(
);
flushSegment(request, destination, completedRootSegment, null);
request.completedRootSegment = null;
writeCompletedRoot(destination, request.renderState);
writeCompletedRoot(
destination,
request.resumableState,
request.renderState,
);
}
writeHoistables(destination, request.resumableState, request.renderState);