Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Apr 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion fixtures/ssr/server/render.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import {renderToPipeableStream} from 'react-dom/server';
import {Writable} from 'stream';

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

Expand All @@ -14,19 +15,52 @@ 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']],
onShellReady() {
// 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.
Expand Down
1 change: 1 addition & 0 deletions fixtures/ssr/src/components/Chrome.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)};`,
Expand Down
136 changes: 117 additions & 19 deletions packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -367,7 +368,7 @@ export function createRenderState(
nonce === undefined
? startInlineScript
: stringToPrecomputedChunk(
'<script nonce="' + escapeTextForBrowser(nonce) + '">',
'<script nonce="' + escapeTextForBrowser(nonce) + '"',
);
const idPrefix = resumableState.idPrefix;

Expand All @@ -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,
);
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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 {
Comment on lines +1977 to +1991
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not following how this doesn't lead to possible duplicate writing of the id.

If we included it as part of the bootstrap scripts and sent the shell and then later we render a form that requires this runtime the bootstrapChunks will be empty and we'll end up emitting it again.

it may just require that we track whether the bootstrap scripts were actually emitted. Or maybe we just do the template trick and never try to use this script as a vehicle for the expected id.

Also, i don't understand how in the above implementation you are deciding whether or not this runtime injection is happening within the shell vs at arbitrary later point. If we knew it was the shell we could then use the bootstrapChunks.length === 0 as the signal that no other thing was about to emit this id but I don't think that actually works here

// 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,
);
}
}
}

Expand Down Expand Up @@ -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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition here isn't symmetrical with the one in writePreambleStart. Maybe that's ok because the ID is essentially reserved and inert without the associated expect link but if we want to actually be precise we need to know if we are in a mode that never emits the paint blocking link

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean the skipExpect hack? I override all of writeCompletedRoot in ReactMarkup. That's in line with how the rest of those overrides work.

987bb1f

However, for the preamble, I still need to write most of it and just exclude this one thing. I'm not sure how to layer that override yet. Maybe it becomes a render state config at some point but for now I wanted it to be compiled out.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh yeah, and the only other place is in the form replaying runtime but that is not part of markup output either?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

those error if you use functions in forms in markup

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but also markup can't outline anything so there's no "completed boundary" instructions written. In fact that whole thing is stubbed out.

// 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);
}

Expand Down Expand Up @@ -4400,6 +4446,7 @@ export function writeCompletedSegmentInstruction(
resumableState.streamingFormat === ScriptStreamingFormat;
if (scriptFormat) {
writeChunk(destination, renderState.startInlineScript);
writeChunk(destination, endOfStartTag);
if (
(resumableState.instructions & SentCompleteSegmentFunction) ===
NothingSent
Expand Down Expand Up @@ -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) ===
Expand Down Expand Up @@ -4591,6 +4639,7 @@ export function writeClientRenderBoundaryInstruction(
resumableState.streamingFormat === ScriptStreamingFormat;
if (scriptFormat) {
writeChunk(destination, renderState.startInlineScript);
writeChunk(destination, endOfStartTag);
if (
(resumableState.instructions & SentClientRenderFunction) ===
NothingSent
Expand Down Expand Up @@ -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
Expand All @@ -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 (
Expand Down Expand Up @@ -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++) {
Expand Down
11 changes: 6 additions & 5 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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">',
);
});

Expand Down Expand Up @@ -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>',
Expand Down Expand Up @@ -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>',
Expand Down Expand Up @@ -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>',
);
});

Expand Down Expand Up @@ -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>',
);
});

Expand Down
Loading
Loading