mirror of
https://github.com/zebrajr/react.git
synced 2025-12-06 12:20:20 +01:00
[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:
parent
693803a9bb
commit
143d3e1b89
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)};`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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++) {
|
||||
|
|
|
|||
|
|
@ -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>',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>"`,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>"`,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>"`,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = {};
|
||||
|
|
|
|||
|
|
@ -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>',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>"`,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
6
packages/react-server/src/ReactFizzServer.js
vendored
6
packages/react-server/src/ReactFizzServer.js
vendored
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user