From 8845bea14a4f6cd59f98fd782416f1fc0533d3f5 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 24 Apr 2025 22:03:19 -0400 Subject: [PATCH 1/6] Emit link rel="expect" to block render before the shell has fully loaded --- fixtures/ssr/server/render.js | 36 ++++- fixtures/ssr/src/components/Chrome.js | 1 + .../src/server/ReactFizzConfigDOM.js | 127 ++++++++++++++++-- packages/react-server/src/ReactFizzServer.js | 6 +- 4 files changed, 155 insertions(+), 15 deletions(-) diff --git a/fixtures/ssr/server/render.js b/fixtures/ssr/server/render.js index a4fe698858ab1..e20b9a35dc502 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 5cf81a877f7e3..984c726a02652 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,17 +367,22 @@ 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 4022f227a8abe..f5b01d2462403 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 c442f1813836c..1eefe1a4082e6 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 e97b4a29a7497..2704c243eba48 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 96e6538cd2196..de6e21b557a1d 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 f973a5ed4d6e0..7eecb16cf82f6 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/test-utils/FizzTestUtils.js b/packages/react-dom/src/test-utils/FizzTestUtils.js index 537c64a889a7d..12c768e1a0008 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-server-dom-fb/src/__tests__/ReactDOMServerFB-test.internal.js b/packages/react-server-dom-fb/src/__tests__/ReactDOMServerFB-test.internal.js index 35b41cbd230d0..6d022ceb26c17 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 0b16b3b32114d..80562624eb173 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 f3fa444fc1528..4313c379b70bd 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

', ); }); From 987bb1fea4b5b20482413511f438d99e0fb44963 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 24 Apr 2025 23:31:50 -0400 Subject: [PATCH 3/6] Disable in ReactMarkup --- .../src/server/ReactFizzConfigDOM.js | 3 +- .../react-markup/src/ReactFizzConfigMarkup.js | 32 +++++++++++++++++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index cbf14017d42d8..cfe792fc104b0 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -5033,6 +5033,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 ( @@ -5118,7 +5119,7 @@ export function writePreambleStart( renderState.bulkPreloads.forEach(flushResource, destination); renderState.bulkPreloads.clear(); - if (htmlChunks || headChunks) { + 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 diff --git a/packages/react-markup/src/ReactFizzConfigMarkup.js b/packages/react-markup/src/ReactFizzConfigMarkup.js index 3d08ed1ee64a2..444952dc58502 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; From 6112766b894611fb77821f1991ea8c5a2d122421 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 24 Apr 2025 23:56:50 -0400 Subject: [PATCH 4/6] Update Float --- .../src/__tests__/ReactDOMFloat-test.js | 9 +++-- .../src/__tests__/ReactDOMLegacyFloat-test.js | 3 +- .../ReactDOMSingletonComponents-test.js | 5 ++- .../src/__tests__/ReactRenderDocument-test.js | 34 ++++++++++++++----- 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 7404cec64a00c..5328a4ac9e055 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 52c9746abdb4f..f2cabafc9f575 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 84db05bc779db..d887972e92ca1 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 9522a920bc291..2b54bc90090e4 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', ); }); From 87a13d09c04f85ea7751f37796dd83cf478c5e02 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 25 Apr 2025 11:42:15 -0400 Subject: [PATCH 5/6] Track whether we already pushed a shell id on resumableState.instructions --- .../src/server/ReactFizzConfigDOM.js | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index cfe792fc104b0..166c44f4c140c 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -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 @@ -371,8 +372,6 @@ export function createRenderState( ); const idPrefix = resumableState.idPrefix; - let needsShellId = true; - const bootstrapChunks: Array = []; let externalRuntimeScript: null | ExternalRuntimeScript = null; const {bootstrapScriptContent, bootstrapScripts, bootstrapModules} = @@ -380,7 +379,6 @@ export function createRenderState( if (bootstrapScriptContent !== undefined) { bootstrapChunks.push(inlineScriptWithNonce); pushCompletedShellIdAttribute(bootstrapChunks, resumableState); - needsShellId = false; bootstrapChunks.push( endOfStartTag, stringToChunk(escapeEntireInlineScriptContent(bootstrapScriptContent)), @@ -555,10 +553,7 @@ export function createRenderState( attributeEnd, ); } - if (needsShellId) { - pushCompletedShellIdAttribute(bootstrapChunks, resumableState); - needsShellId = false; - } + pushCompletedShellIdAttribute(bootstrapChunks, resumableState); bootstrapChunks.push(endAsyncScript); } } @@ -615,10 +610,7 @@ export function createRenderState( attributeEnd, ); } - if (needsShellId) { - pushCompletedShellIdAttribute(bootstrapChunks, resumableState); - needsShellId = false; - } + pushCompletedShellIdAttribute(bootstrapChunks, resumableState); bootstrapChunks.push(endAsyncScript); } } @@ -5015,6 +5007,10 @@ function pushCompletedShellIdAttribute( target: Array, resumableState: ResumableState, ): void { + if ((resumableState.instructions & SentCompletedShellId) !== NothingSent) { + return; + } + resumableState.instructions |= SentCompletedShellId; const idPrefix = resumableState.idPrefix; const shellId = '\u00AB' + idPrefix + 'R\u00BB'; target.push( From d5e45b9b0a127f08423b11dbbc49fbdef977e182 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 25 Apr 2025 11:45:53 -0400 Subject: [PATCH 6/6] Check instruction instead of bootstrap chunks as signal whether to emit template --- packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 166c44f4c140c..6109a0f023ace 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -4116,8 +4116,8 @@ export function writeCompletedRoot( // 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. - const bootstrapChunks = renderState.bootstrapChunks; - if (bootstrapChunks.length === 0) { + if ((resumableState.instructions & SentCompletedShellId) === NothingSent) { + const bootstrapChunks = renderState.bootstrapChunks; bootstrapChunks.push(startChunkForTag('template')); pushCompletedShellIdAttribute(bootstrapChunks, resumableState); bootstrapChunks.push(endOfStartTag, endChunkForTag('template'));