From 143d3e1b89d7f64d607bbfc844d1324b39ed93dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 25 Apr 2025 11:52:28 -0400 Subject: [PATCH] [Fizz] Emit link rel="expect" to block render before the shell has fully loaded (#33016) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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

hello world

... ``` 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 `` 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 `` or `` 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. --- fixtures/ssr/server/render.js | 36 ++++- fixtures/ssr/src/components/Chrome.js | 1 + .../src/server/ReactFizzConfigDOM.js | 136 +++++++++++++++--- .../src/__tests__/ReactDOMFizzServer-test.js | 11 +- .../ReactDOMFizzServerBrowser-test.js | 8 +- .../__tests__/ReactDOMFizzServerEdge-test.js | 2 +- .../__tests__/ReactDOMFizzServerNode-test.js | 4 +- .../src/__tests__/ReactDOMFizzStatic-test.js | 5 +- .../ReactDOMFizzStaticBrowser-test.js | 20 ++- .../__tests__/ReactDOMFizzStaticNode-test.js | 4 +- .../src/__tests__/ReactDOMFloat-test.js | 9 +- .../src/__tests__/ReactDOMLegacyFloat-test.js | 3 +- .../ReactDOMSingletonComponents-test.js | 5 +- .../src/__tests__/ReactRenderDocument-test.js | 34 +++-- .../react-dom/src/test-utils/FizzTestUtils.js | 5 +- .../react-markup/src/ReactFizzConfigMarkup.js | 32 ++++- .../ReactDOMServerFB-test.internal.js | 2 +- .../src/__tests__/ReactFlightDOM-test.js | 13 +- .../__tests__/ReactFlightDOMBrowser-test.js | 4 +- packages/react-server/src/ReactFizzServer.js | 6 +- 20 files changed, 274 insertions(+), 66 deletions(-) diff --git a/fixtures/ssr/server/render.js b/fixtures/ssr/server/render.js index a4fe698858..e20b9a35dc 100644 --- a/fixtures/ssr/server/render.js +++ b/fixtures/ssr/server/render.js @@ -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(, { 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. diff --git a/fixtures/ssr/src/components/Chrome.js b/fixtures/ssr/src/components/Chrome.js index 5cf81a877f..984c726a02 100644 --- a/fixtures/ssr/src/components/Chrome.js +++ b/fixtures/ssr/src/components/Chrome.js @@ -37,6 +37,7 @@ export default class Chrome extends Component { +

This should appear in the first paint.

'); const startScriptSrc = stringToPrecomputedChunk(''); +const scriptNonce = stringToPrecomputedChunk(' nonce="'); +const scriptIntegirty = stringToPrecomputedChunk(' integrity="'); +const scriptCrossOrigin = stringToPrecomputedChunk(' crossorigin="'); +const endAsyncScript = stringToPrecomputedChunk(' async="">'); /** * 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( - '', + '' + + '', ); }); @@ -4189,7 +4190,7 @@ describe('ReactDOMFizzServer', () => { renderOptions.unstable_externalRuntimeSrc, ).map(n => n.outerHTML), ).toEqual([ - '', + '', '', '', '', @@ -4276,7 +4277,7 @@ describe('ReactDOMFizzServer', () => { renderOptions.unstable_externalRuntimeSrc, ).map(n => n.outerHTML), ).toEqual([ - '', + '', '', '', '', @@ -4512,7 +4513,7 @@ describe('ReactDOMFizzServer', () => { // the html should be as-is expect(document.documentElement.innerHTML).toEqual( - '

hello world!

', + '

hello world!

', ); }); @@ -6492,7 +6493,7 @@ describe('ReactDOMFizzServer', () => { }); expect(document.documentElement.outerHTML).toEqual( - '', + '', ); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js index 4022f227a8..f5b01d2462 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js @@ -85,7 +85,7 @@ describe('ReactDOMFizzServerBrowser', () => { ); const result = await readResult(stream); expect(result).toMatchInlineSnapshot( - `"hello world"`, + `"hello world"`, ); }); @@ -99,7 +99,7 @@ describe('ReactDOMFizzServerBrowser', () => { ); const result = await readResult(stream); expect(result).toMatchInlineSnapshot( - `"
hello world
"`, + `"
hello world
"`, ); }); @@ -529,7 +529,7 @@ describe('ReactDOMFizzServerBrowser', () => { const result = await readResult(stream); expect(result).toEqual( - 'foobar', + 'foobar', ); }); @@ -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 - `"
hello world
"`, + `"
hello world
"`, ); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerEdge-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerEdge-test.js index c442f18138..1eefe1a408 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServerEdge-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerEdge-test.js @@ -72,7 +72,7 @@ describe('ReactDOMFizzServerEdge', () => { }); expect(result).toMatchInlineSnapshot( - `"
hello
"`, + `"
hello
"`, ); }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js index e97b4a29a7..2704c243eb 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js @@ -79,7 +79,7 @@ describe('ReactDOMFizzServerNode', () => { }); // with Float, we emit empty heads if they are elided when rendering expect(output.result).toMatchInlineSnapshot( - `"hello world"`, + `"hello world"`, ); }); @@ -97,7 +97,7 @@ describe('ReactDOMFizzServerNode', () => { pipe(writable); }); expect(output.result).toMatchInlineSnapshot( - `"
hello world
"`, + `"
hello world
"`, ); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js index 96e6538cd2..de6e21b557 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js @@ -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; diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js index f973a5ed4d..7eecb16cf8 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js @@ -187,7 +187,7 @@ describe('ReactDOMFizzStaticBrowser', () => { ); const prelude = await readContent(result.prelude); expect(prelude).toMatchInlineSnapshot( - `"hello world"`, + `"hello world"`, ); }); @@ -201,7 +201,7 @@ describe('ReactDOMFizzStaticBrowser', () => { ); const prelude = await readContent(result.prelude); expect(prelude).toMatchInlineSnapshot( - `"
hello world
"`, + `"
hello world
"`, ); }); @@ -1428,7 +1428,8 @@ describe('ReactDOMFizzStaticBrowser', () => { expect(await readContent(content)).toBe( '' + '' + - 'Hello', + '' + + 'Hello', ); }); @@ -1474,7 +1475,8 @@ describe('ReactDOMFizzStaticBrowser', () => { expect(await readContent(content)).toBe( '' + '' + - 'Hello', + '' + + 'Hello', ); }); @@ -1525,7 +1527,8 @@ describe('ReactDOMFizzStaticBrowser', () => { expect(await readContent(content)).toBe( '' + '' + - '
Hello
', + '' + + '
Hello
', ); }); @@ -1607,7 +1610,8 @@ describe('ReactDOMFizzStaticBrowser', () => { let result = decoder.decode(value, {stream: true}); expect(result).toBe( - 'hello', + '' + + 'hello', ); await 1; @@ -1631,7 +1635,9 @@ describe('ReactDOMFizzStaticBrowser', () => { const slice = result.slice(0, instructionIndex + '$RC'.length); expect(slice).toBe( - 'hello"`, + `"
hello world
"`, ); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 7404cec64a..5328a4ac9e 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -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([ - 'foobar', + '' + + 'foo' + + 'bar', '', ]); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMLegacyFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMLegacyFloat-test.js index 52c9746abd..f2cabafc9f 100644 --- a/packages/react-dom/src/__tests__/ReactDOMLegacyFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMLegacyFloat-test.js @@ -34,7 +34,8 @@ describe('ReactDOMFloat', () => { ); expect(result).toEqual( - 'title', + '' + + 'title', ); }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMSingletonComponents-test.js b/packages/react-dom/src/__tests__/ReactDOMSingletonComponents-test.js index 84db05bc77..d887972e92 100644 --- a/packages/react-dom/src/__tests__/ReactDOMSingletonComponents-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMSingletonComponents-test.js @@ -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 = {}; diff --git a/packages/react-dom/src/__tests__/ReactRenderDocument-test.js b/packages/react-dom/src/__tests__/ReactRenderDocument-test.js index 9522a920bc..2b54bc9009 100644 --- a/packages/react-dom/src/__tests__/ReactRenderDocument-test.js +++ b/packages/react-dom/src/__tests__/ReactRenderDocument-test.js @@ -77,12 +77,16 @@ describe('rendering React components at document', () => { await act(() => { root = ReactDOMClient.hydrateRoot(testDocument, ); }); - expect(testDocument.body.innerHTML).toBe('Hello world'); + expect(testDocument.body.innerHTML).toBe( + 'Hello world' + '', + ); await act(() => { root.render(); }); - expect(testDocument.body.innerHTML).toBe('Hello moon'); + expect(testDocument.body.innerHTML).toBe( + 'Hello moon' + '', + ); expect(body === testDocument.body).toBe(true); }); @@ -107,7 +111,9 @@ describe('rendering React components at document', () => { await act(() => { root = ReactDOMClient.hydrateRoot(testDocument, ); }); - expect(testDocument.body.innerHTML).toBe('Hello world'); + expect(testDocument.body.innerHTML).toBe( + 'Hello world' + '', + ); 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(''); + expect(originalHead.innerHTML).toBe( + '', + ); }); it('should not be able to switch root constructors', async () => { @@ -157,13 +165,17 @@ describe('rendering React components at document', () => { root = ReactDOMClient.hydrateRoot(testDocument, ); }); - expect(testDocument.body.innerHTML).toBe('Hello world'); + expect(testDocument.body.innerHTML).toBe( + 'Hello world' + '', + ); await act(() => { root.render(); }); - expect(testDocument.body.innerHTML).toBe('Goodbye world'); + expect(testDocument.body.innerHTML).toBe( + '' + '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' + '', + ); }); 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', ); }); diff --git a/packages/react-dom/src/test-utils/FizzTestUtils.js b/packages/react-dom/src/test-utils/FizzTestUtils.js index 537c64a889..12c768e1a0 100644 --- a/packages/react-dom/src/test-utils/FizzTestUtils.js +++ b/packages/react-dom/src/test-utils/FizzTestUtils.js @@ -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; diff --git a/packages/react-markup/src/ReactFizzConfigMarkup.js b/packages/react-markup/src/ReactFizzConfigMarkup.js index 3d08ed1ee6..444952dc58 100644 --- a/packages/react-markup/src/ReactFizzConfigMarkup.js +++ b/packages/react-markup/src/ReactFizzConfigMarkup.js @@ -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; diff --git a/packages/react-server-dom-fb/src/__tests__/ReactDOMServerFB-test.internal.js b/packages/react-server-dom-fb/src/__tests__/ReactDOMServerFB-test.internal.js index 35b41cbd23..6d022ceb26 100644 --- a/packages/react-server-dom-fb/src/__tests__/ReactDOMServerFB-test.internal.js +++ b/packages/react-server-dom-fb/src/__tests__/ReactDOMServerFB-test.internal.js @@ -59,7 +59,7 @@ describe('ReactDOMServerFB', () => { }); const result = readResult(stream); expect(result).toMatchInlineSnapshot( - `"
hello world
"`, + `"
hello world
"`, ); }); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js index 0b16b3b321..80562624eb 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -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( '' + - '

hello world

', + '' + + '' + + '

hello world

', ); expect(content2).toEqual( '' + - '

hello world

', + '' + + '' + + '

hello world

', ); }); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index f3fa444fc1..4313c379b7 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -1899,8 +1899,8 @@ describe('ReactFlightDOMBrowser', () => { } expect(content).toEqual( - '' + - '

hello world

', + '' + + '

hello world

', ); }); diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 487751c6be..52d677ad1b 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -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);