Skip to content

Commit 143d3e1

Browse files
authored
[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.
1 parent 693803a commit 143d3e1

20 files changed

+274
-66
lines changed

fixtures/ssr/server/render.js

+35-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react';
22
import {renderToPipeableStream} from 'react-dom/server';
3+
import {Writable} from 'stream';
34

45
import App from '../src/components/App';
56

@@ -14,19 +15,52 @@ if (process.env.NODE_ENV === 'development') {
1415
assets = require('../build/asset-manifest.json');
1516
}
1617

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+
1747
export default function render(url, res) {
1848
res.socket.on('error', error => {
1949
// Log fatal errors
2050
console.error('Fatal', error);
2151
});
52+
console.log('hello');
2253
let didError = false;
2354
const {pipe, abort} = renderToPipeableStream(<App assets={assets} />, {
2455
bootstrapScripts: [assets['main.js']],
2556
onShellReady() {
2657
// If something errored before we started streaming, we set the error code appropriately.
2758
res.statusCode = didError ? 500 : 200;
2859
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);
3064
},
3165
onShellError(x) {
3266
// Something errored before we could complete the shell so we emit an alternative shell.

fixtures/ssr/src/components/Chrome.js

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export default class Chrome extends Component {
3737
</div>
3838
</Theme.Provider>
3939
</Suspense>
40+
<p>This should appear in the first paint.</p>
4041
<script
4142
dangerouslySetInnerHTML={{
4243
__html: `assetManifest = ${JSON.stringify(assets)};`,

packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js

+117-19
Original file line numberDiff line numberDiff line change
@@ -120,12 +120,13 @@ const ScriptStreamingFormat: StreamingFormat = 0;
120120
const DataStreamingFormat: StreamingFormat = 1;
121121

122122
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;
129130

130131
// Per request, global state that is not contextual to the rendering subtree.
131132
// This cannot be resumed and therefore should only contain things that are
@@ -289,15 +290,15 @@ export type ResumableState = {
289290

290291
const dataElementQuotedEnd = stringToPrecomputedChunk('"></template>');
291292

292-
const startInlineScript = stringToPrecomputedChunk('<script>');
293+
const startInlineScript = stringToPrecomputedChunk('<script');
293294
const endInlineScript = stringToPrecomputedChunk('</script>');
294295

295296
const startScriptSrc = stringToPrecomputedChunk('<script src="');
296297
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>');
301302

302303
/**
303304
* This escaping function is designed to work with with inline scripts where the entire
@@ -367,7 +368,7 @@ export function createRenderState(
367368
nonce === undefined
368369
? startInlineScript
369370
: stringToPrecomputedChunk(
370-
'<script nonce="' + escapeTextForBrowser(nonce) + '">',
371+
'<script nonce="' + escapeTextForBrowser(nonce) + '"',
371372
);
372373
const idPrefix = resumableState.idPrefix;
373374

@@ -376,8 +377,10 @@ export function createRenderState(
376377
const {bootstrapScriptContent, bootstrapScripts, bootstrapModules} =
377378
resumableState;
378379
if (bootstrapScriptContent !== undefined) {
380+
bootstrapChunks.push(inlineScriptWithNonce);
381+
pushCompletedShellIdAttribute(bootstrapChunks, resumableState);
379382
bootstrapChunks.push(
380-
inlineScriptWithNonce,
383+
endOfStartTag,
381384
stringToChunk(escapeEntireInlineScriptContent(bootstrapScriptContent)),
382385
endInlineScript,
383386
);
@@ -527,25 +530,30 @@ export function createRenderState(
527530
bootstrapChunks.push(
528531
startScriptSrc,
529532
stringToChunk(escapeTextForBrowser(src)),
533+
attributeEnd,
530534
);
531535
if (nonce) {
532536
bootstrapChunks.push(
533537
scriptNonce,
534538
stringToChunk(escapeTextForBrowser(nonce)),
539+
attributeEnd,
535540
);
536541
}
537542
if (typeof integrity === 'string') {
538543
bootstrapChunks.push(
539544
scriptIntegirty,
540545
stringToChunk(escapeTextForBrowser(integrity)),
546+
attributeEnd,
541547
);
542548
}
543549
if (typeof crossOrigin === 'string') {
544550
bootstrapChunks.push(
545551
scriptCrossOrigin,
546552
stringToChunk(escapeTextForBrowser(crossOrigin)),
553+
attributeEnd,
547554
);
548555
}
556+
pushCompletedShellIdAttribute(bootstrapChunks, resumableState);
549557
bootstrapChunks.push(endAsyncScript);
550558
}
551559
}
@@ -579,26 +587,30 @@ export function createRenderState(
579587
bootstrapChunks.push(
580588
startModuleSrc,
581589
stringToChunk(escapeTextForBrowser(src)),
590+
attributeEnd,
582591
);
583-
584592
if (nonce) {
585593
bootstrapChunks.push(
586594
scriptNonce,
587595
stringToChunk(escapeTextForBrowser(nonce)),
596+
attributeEnd,
588597
);
589598
}
590599
if (typeof integrity === 'string') {
591600
bootstrapChunks.push(
592601
scriptIntegirty,
593602
stringToChunk(escapeTextForBrowser(integrity)),
603+
attributeEnd,
594604
);
595605
}
596606
if (typeof crossOrigin === 'string') {
597607
bootstrapChunks.push(
598608
scriptCrossOrigin,
599609
stringToChunk(escapeTextForBrowser(crossOrigin)),
610+
attributeEnd,
600611
);
601612
}
613+
pushCompletedShellIdAttribute(bootstrapChunks, resumableState);
602614
bootstrapChunks.push(endAsyncScript);
603615
}
604616
}
@@ -1960,11 +1972,32 @@ function injectFormReplayingRuntime(
19601972
(!enableFizzExternalRuntime || !renderState.externalRuntimeScript)
19611973
) {
19621974
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+
}
19682001
}
19692002
}
19702003

@@ -4075,8 +4108,21 @@ function writeBootstrap(
40754108

40764109
export function writeCompletedRoot(
40774110
destination: Destination,
4111+
resumableState: ResumableState,
40784112
renderState: RenderState,
40794113
): 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+
}
40804126
return writeBootstrap(destination, renderState);
40814127
}
40824128

@@ -4400,6 +4446,7 @@ export function writeCompletedSegmentInstruction(
44004446
resumableState.streamingFormat === ScriptStreamingFormat;
44014447
if (scriptFormat) {
44024448
writeChunk(destination, renderState.startInlineScript);
4449+
writeChunk(destination, endOfStartTag);
44034450
if (
44044451
(resumableState.instructions & SentCompleteSegmentFunction) ===
44054452
NothingSent
@@ -4481,6 +4528,7 @@ export function writeCompletedBoundaryInstruction(
44814528
resumableState.streamingFormat === ScriptStreamingFormat;
44824529
if (scriptFormat) {
44834530
writeChunk(destination, renderState.startInlineScript);
4531+
writeChunk(destination, endOfStartTag);
44844532
if (requiresStyleInsertion) {
44854533
if (
44864534
(resumableState.instructions & SentCompleteBoundaryFunction) ===
@@ -4591,6 +4639,7 @@ export function writeClientRenderBoundaryInstruction(
45914639
resumableState.streamingFormat === ScriptStreamingFormat;
45924640
if (scriptFormat) {
45934641
writeChunk(destination, renderState.startInlineScript);
4642+
writeChunk(destination, endOfStartTag);
45944643
if (
45954644
(resumableState.instructions & SentClientRenderFunction) ===
45964645
NothingSent
@@ -4933,6 +4982,44 @@ function preloadLateStyles(this: Destination, styleQueue: StyleQueue) {
49334982
styleQueue.sheets.clear();
49344983
}
49354984

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+
49365023
// We don't bother reporting backpressure at the moment because we expect to
49375024
// flush the entire preamble in a single pass. This probably should be modified
49385025
// in the future to be backpressure sensitive but that requires a larger refactor
@@ -4942,6 +5029,7 @@ export function writePreambleStart(
49425029
resumableState: ResumableState,
49435030
renderState: RenderState,
49445031
willFlushAllSegments: boolean,
5032+
skipExpect?: boolean, // Used as an override by ReactFizzConfigMarkup
49455033
): void {
49465034
// This function must be called exactly once on every request
49475035
if (
@@ -5027,6 +5115,16 @@ export function writePreambleStart(
50275115
renderState.bulkPreloads.forEach(flushResource, destination);
50285116
renderState.bulkPreloads.clear();
50295117

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+
50305128
// Write embedding hoistableChunks
50315129
const hoistableChunks = renderState.hoistableChunks;
50325130
for (i = 0; i < hoistableChunks.length; i++) {

packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

+6-5
Original file line numberDiff line numberDiff line change
@@ -3580,7 +3580,8 @@ describe('ReactDOMFizzServer', () => {
35803580
expect(document.head.innerHTML).toBe(
35813581
'<script type="importmap">' +
35823582
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">',
35843585
);
35853586
});
35863587

@@ -4189,7 +4190,7 @@ describe('ReactDOMFizzServer', () => {
41894190
renderOptions.unstable_externalRuntimeSrc,
41904191
).map(n => n.outerHTML),
41914192
).toEqual([
4192-
'<script src="foo" async=""></script>',
4193+
'<script src="foo" id="«R»" async=""></script>',
41934194
'<script src="bar" async=""></script>',
41944195
'<script src="baz" integrity="qux" async=""></script>',
41954196
'<script type="module" src="quux" async=""></script>',
@@ -4276,7 +4277,7 @@ describe('ReactDOMFizzServer', () => {
42764277
renderOptions.unstable_externalRuntimeSrc,
42774278
).map(n => n.outerHTML),
42784279
).toEqual([
4279-
'<script src="foo" async=""></script>',
4280+
'<script src="foo" id="«R»" async=""></script>',
42804281
'<script src="bar" async=""></script>',
42814282
'<script src="baz" crossorigin="" async=""></script>',
42824283
'<script src="qux" crossorigin="" async=""></script>',
@@ -4512,7 +4513,7 @@ describe('ReactDOMFizzServer', () => {
45124513

45134514
// the html should be as-is
45144515
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>',
45164517
);
45174518
});
45184519

@@ -6492,7 +6493,7 @@ describe('ReactDOMFizzServer', () => {
64926493
});
64936494

64946495
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>',
64966497
);
64976498
});
64986499

0 commit comments

Comments
 (0)