From ee7fee8f8875052afde53c5bfc8aedad43ff9d8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Thu, 1 May 2025 16:09:37 -0400 Subject: [PATCH] [Fizz] Batch Suspense Boundary Reveal with Throttle (#33076) Stacked on #33073. React semantics is that Suspense boundaries reveal with a throttle (300ms). That helps avoid flashing reveals when a stream reveals many individual steps back to back. It can also improve overall performance by batching the layout and paint work that has to happen at each step. Unfortunately we never implemented this for SSR streaming - only for client navigations. This is highly noticeable on very dynamic sites with lots of Suspense boundaries. It can look good with a client nav but feel glitchy when you reload the page or initial load. This fixes the Fizz runtime to be throttled and reveals batched into a single paint at a time. We do this by first tracking the last paint after the complete (this will be the first paint if `rel="expect"` is respected). Then in the `completeBoundary` operation we queue the operation and then flush it all into a throttled batch. Another motivation is that View Transitions need to operate as a batch and individual steps get queued in a sequence so it's extra important to include as much content as possible in each animated step. This will be done in a follow up for SSR View Transitions. --- .../src/server/ReactFizzConfigDOM.js | 73 +++++++++-- .../ReactDOMFizzInlineCompleteBoundary.js | 2 + .../ReactDOMFizzInlineShellTime.js | 5 + ...actDOMFizzInstructionSetExternalRuntime.js | 17 +++ ...tDOMFizzInstructionSetInlineCodeStrings.js | 6 +- .../ReactDOMFizzInstructionSetShared.js | 121 +++++++++++------- .../src/__tests__/ReactDOMFizzServer-test.js | 12 +- .../ReactDOMFizzStaticBrowser-test.js | 20 ++- .../src/__tests__/ReactDOMFloat-test.js | 16 ++- .../react-markup/src/ReactFizzConfigMarkup.js | 1 + .../src/ReactNoopServer.js | 3 + packages/react-server/src/ReactFizzServer.js | 9 +- .../rollup/generate-inline-fizz-runtime.js | 6 +- 13 files changed, 222 insertions(+), 69 deletions(-) create mode 100644 packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineShellTime.js diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index bc3a7c36eb..371a1dd229 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -81,6 +81,7 @@ import { completeBoundaryWithStyles as styleInsertionFunction, completeSegment as completeSegmentFunction, formReplaying as formReplayingRuntime, + markShellTime, } from './fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings'; import {getValueDescriptorExpectingObjectForWarning} from '../shared/ReactDOMResourceValidation'; @@ -120,13 +121,14 @@ const ScriptStreamingFormat: StreamingFormat = 0; const DataStreamingFormat: StreamingFormat = 1; export type InstructionState = number; -const NothingSent /* */ = 0b000000; -const SentCompleteSegmentFunction /* */ = 0b000001; -const SentCompleteBoundaryFunction /* */ = 0b000010; -const SentClientRenderFunction /* */ = 0b000100; -const SentStyleInsertionFunction /* */ = 0b001000; -const SentFormReplayingRuntime /* */ = 0b010000; -const SentCompletedShellId /* */ = 0b100000; +const NothingSent /* */ = 0b0000000; +const SentCompleteSegmentFunction /* */ = 0b0000001; +const SentCompleteBoundaryFunction /* */ = 0b0000010; +const SentClientRenderFunction /* */ = 0b0000100; +const SentStyleInsertionFunction /* */ = 0b0001000; +const SentFormReplayingRuntime /* */ = 0b0010000; +const SentCompletedShellId /* */ = 0b0100000; +const SentMarkShellTime /* */ = 0b1000000; // Per request, global state that is not contextual to the rendering subtree. // This cannot be resumed and therefore should only contain things that are @@ -4107,21 +4109,53 @@ function writeBootstrap( return true; } -export function writeCompletedRoot( +const shellTimeRuntimeScript = stringToPrecomputedChunk(markShellTime); + +function writeShellTimeInstruction( destination: Destination, resumableState: ResumableState, renderState: RenderState, ): boolean { + if ( + enableFizzExternalRuntime && + resumableState.streamingFormat !== ScriptStreamingFormat + ) { + // External runtime always tracks the shell time in the runtime. + return true; + } + if ((resumableState.instructions & SentMarkShellTime) !== NothingSent) { + // We already sent this instruction. + return true; + } + resumableState.instructions |= SentMarkShellTime; + writeChunk(destination, renderState.startInlineScript); + writeCompletedShellIdAttribute(destination, resumableState); + writeChunk(destination, endOfStartTag); + writeChunk(destination, shellTimeRuntimeScript); + return writeChunkAndReturn(destination, endInlineScript); +} + +export function writeCompletedRoot( + destination: Destination, + resumableState: ResumableState, + renderState: RenderState, + isComplete: boolean, +): boolean { + if (!isComplete) { + // If we're not already fully complete, we might complete another boundary. If so, + // we need to track the paint time of the shell so we know how much to throttle the reveal. + writeShellTimeInstruction(destination, resumableState, renderState); + } 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')); + writeChunk(destination, startChunkForTag('template')); + writeCompletedShellIdAttribute(destination, resumableState); + writeChunk(destination, endOfStartTag); + writeChunk(destination, endChunkForTag('template')); } } return writeBootstrap(destination, renderState); @@ -5015,6 +5049,21 @@ function writeBlockingRenderInstruction( const completedShellIdAttributeStart = stringToPrecomputedChunk(' id="'); +function writeCompletedShellIdAttribute( + destination: Destination, + resumableState: ResumableState, +): void { + if ((resumableState.instructions & SentCompletedShellId) !== NothingSent) { + return; + } + resumableState.instructions |= SentCompletedShellId; + const idPrefix = resumableState.idPrefix; + const shellId = '\u00AB' + idPrefix + 'R\u00BB'; + writeChunk(destination, completedShellIdAttributeStart); + writeChunk(destination, stringToChunk(escapeTextForBrowser(shellId))); + writeChunk(destination, attributeEnd); +} + function pushCompletedShellIdAttribute( target: Array, resumableState: ResumableState, diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineCompleteBoundary.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineCompleteBoundary.js index 1cbe1e222a..44a81c6446 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineCompleteBoundary.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineCompleteBoundary.js @@ -2,4 +2,6 @@ import {completeBoundary} from './ReactDOMFizzInstructionSetShared'; // This is a string so Closure's advanced compilation mode doesn't mangle it. // eslint-disable-next-line dot-notation +window['$RB'] = []; +// eslint-disable-next-line dot-notation window['$RC'] = completeBoundary; diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineShellTime.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineShellTime.js new file mode 100644 index 0000000000..9e52a25aa7 --- /dev/null +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineShellTime.js @@ -0,0 +1,5 @@ +// Track the paint time of the shell +requestAnimationFrame(() => { + // eslint-disable-next-line dot-notation + window['$RT'] = performance.now(); +}); diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetExternalRuntime.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetExternalRuntime.js index 2481752425..845ea5b566 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetExternalRuntime.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetExternalRuntime.js @@ -13,9 +13,26 @@ import { // This is a string so Closure's advanced compilation mode doesn't mangle it. // These will be renamed to local references by the external-runtime-plugin. window['$RM'] = new Map(); +window['$RB'] = []; window['$RX'] = clientRenderBoundary; window['$RC'] = completeBoundary; window['$RR'] = completeBoundaryWithStyles; window['$RS'] = completeSegment; listenToFormSubmissionsForReplaying(); + +// Track the paint time of the shell. +const entries = performance.getEntriesByType + ? performance.getEntriesByType('paint') + : []; +if (entries.length > 0) { + // We might have already painted before this external runtime loaded. In that case we + // try to get the first paint from the performance metrics to avoid delaying further + // than necessary. + window['$RT'] = entries[0].startTime; +} else { + // Otherwise we wait for the next rAF for it. + requestAnimationFrame(() => { + window['$RT'] = performance.now(); + }); +} diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js index 97a1a7b80a..0653761df7 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js @@ -1,12 +1,14 @@ // This is a generated file. The source files are in react-dom-bindings/src/server/fizz-instruction-set. // The build script is at scripts/rollup/generate-inline-fizz-runtime.js. // Run `yarn generate-inline-fizz-runtime` to generate. +export const markShellTime = + 'requestAnimationFrame(function(){$RT=performance.now()});'; export const clientRenderBoundary = '$RX=function(b,c,d,e,f){var a=document.getElementById(b);a&&(b=a.previousSibling,b.data="$!",a=a.dataset,c&&(a.dgst=c),d&&(a.msg=d),e&&(a.stck=e),f&&(a.cstck=f),b._reactRetry&&b._reactRetry())};'; export const completeBoundary = - '$RC=function(a,d){if(d=document.getElementById(d))if(d.parentNode.removeChild(d),a=document.getElementById(a)){a=a.previousSibling;var f=a.parentNode,b=a.nextSibling,e=0;do{if(b&&8===b.nodeType){var c=b.data;if("/$"===c||"/&"===c)if(0===e)break;else e--;else"$"!==c&&"$?"!==c&&"$!"!==c&&"&"!==c||e++}c=b.nextSibling;f.removeChild(b);b=c}while(b);for(;d.firstChild;)f.insertBefore(d.firstChild,b);a.data="$";a._reactRetry&&a._reactRetry()}};'; + '$RB=[];$RC=function(e,c){function m(){$RT=performance.now();var f=$RB;$RB=[];for(var d=0;d { global.Node = global.window.Node; global.addEventListener = global.window.addEventListener; global.MutationObserver = global.window.MutationObserver; + // The Fizz runtime assumes requestAnimationFrame exists so we need to polyfill it. + global.requestAnimationFrame = global.window.requestAnimationFrame = cb => + setTimeout(cb); container = document.getElementById('container'); Scheduler = require('scheduler'); @@ -206,6 +209,7 @@ describe('ReactDOMFizzServer', () => { buffer = ''; if (!bufferedContent) { + jest.runAllTimers(); return; } @@ -314,6 +318,8 @@ describe('ReactDOMFizzServer', () => { div.innerHTML = bufferedContent; await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce); } + // Let throttled boundaries reveal + jest.runAllTimers(); } function resolveText(text) { @@ -602,12 +608,12 @@ describe('ReactDOMFizzServer', () => { ]); // check that there are 6 scripts with a matching nonce: - // The runtime script, an inline bootstrap script, two bootstrap scripts and two bootstrap modules + // The runtime script or initial paint time, an inline bootstrap script, two bootstrap scripts and two bootstrap modules expect( Array.from(container.getElementsByTagName('script')).filter( node => node.getAttribute('nonce') === CSPnonce, ).length, - ).toEqual(gate(flags => flags.shouldUseFizzExternalRuntime) ? 6 : 5); + ).toEqual(6); await act(() => { resolve({default: Text}); @@ -836,7 +842,7 @@ describe('ReactDOMFizzServer', () => { container.childNodes, renderOptions.unstable_externalRuntimeSrc, ).length, - ).toBe(1); + ).toBe(gate(flags => flags.shouldUseFizzExternalRuntime) ? 1 : 2); await act(() => { resolveElement({default: }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js index 2f63c9695b..578b2bf916 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js @@ -38,6 +38,9 @@ describe('ReactDOMFizzStaticBrowser', () => { jest.resetModules(); JSDOM = require('jsdom').JSDOM; + // We need the mocked version of setTimeout inside the document. + window.setTimeout = setTimeout; + Scheduler = require('scheduler'); patchMessageChannel(Scheduler); act = require('internal-test-utils').act; @@ -133,13 +136,18 @@ describe('ReactDOMFizzStaticBrowser', () => { const temp = document.createElement('div'); temp.innerHTML = result; await insertNodesAndExecuteScripts(temp, container, null); + jest.runAllTimers(); } async function readIntoNewDocument(stream) { const content = await readContent(stream); - const jsdom = new JSDOM(content, { - runScripts: 'dangerously', - }); + const jsdom = new JSDOM( + // The Fizz runtime assumes requestAnimationFrame exists so we need to polyfill it. + '' + content, + { + runScripts: 'dangerously', + }, + ); const originalWindow = global.window; const originalDocument = global.document; const originalNavigator = global.navigator; @@ -167,6 +175,7 @@ describe('ReactDOMFizzStaticBrowser', () => { const temp = document.createElement('div'); temp.innerHTML = content; await insertNodesAndExecuteScripts(temp, document.body, null); + jest.runAllTimers(); } it('should call prerender', async () => { @@ -980,6 +989,7 @@ describe('ReactDOMFizzStaticBrowser', () => { // Wait for the instruction microtasks to flush. await 0; await 0; + jest.runAllTimers(); expect(getVisibleChildren(container)).toEqual([ , @@ -1611,7 +1621,7 @@ describe('ReactDOMFizzStaticBrowser', () => { expect(result).toBe( '' + - 'hello', + 'hello', ); await 1; @@ -1636,7 +1646,7 @@ describe('ReactDOMFizzStaticBrowser', () => { expect(slice).toBe( '' + - 'hello' + + 'hello' + '']); }); @@ -3609,6 +3618,7 @@ body { assertConsoleErrorDev([ "Hydration failed because the server rendered HTML didn't match the client.", ]); + jest.runAllTimers(); expect(getMeaningfulChildren(document)).toEqual( @@ -5202,6 +5212,10 @@ body { , ); loadStylesheets(); + // Let the styles flush and then flush the boundaries + await 0; + await 0; + jest.runAllTimers(); assertLog([ 'load stylesheet: shell preinit/shell', 'load stylesheet: shell/shell preinit', diff --git a/packages/react-markup/src/ReactFizzConfigMarkup.js b/packages/react-markup/src/ReactFizzConfigMarkup.js index 444952dc58..800dd46e0a 100644 --- a/packages/react-markup/src/ReactFizzConfigMarkup.js +++ b/packages/react-markup/src/ReactFizzConfigMarkup.js @@ -224,6 +224,7 @@ export function writeCompletedRoot( destination: Destination, resumableState: ResumableState, renderState: RenderState, + isComplete: boolean, ): boolean { // Markup doesn't have any bootstrap scripts nor shell completions. return true; diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index fe00f28b02..119c9885db 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -55,6 +55,7 @@ type Destination = { stack: Array, }; +type ResumableState = null; type RenderState = null; type HoistableState = null; type PreambleState = null; @@ -153,7 +154,9 @@ const ReactNoopServer = ReactFizzServer({ writeCompletedRoot( destination: Destination, + resumableState: ResumableState, renderState: RenderState, + isComplete: boolean, ): boolean { return true; }, diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index f55d6f0804..65efe7c7e8 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -5217,10 +5217,18 @@ function flushCompletedQueues( ); flushSegment(request, destination, completedRootSegment, null); request.completedRootSegment = null; + const isComplete = + request.allPendingTasks === 0 && + request.clientRenderedBoundaries.length === 0 && + request.completedBoundaries.length === 0 && + (request.trackedPostpones === null || + (request.trackedPostpones.rootNodes.length === 0 && + request.trackedPostpones.rootSlots === null)); writeCompletedRoot( destination, request.resumableState, request.renderState, + isComplete, ); } @@ -5293,7 +5301,6 @@ function flushCompletedQueues( } finally { if ( request.allPendingTasks === 0 && - request.pingedTasks.length === 0 && request.clientRenderedBoundaries.length === 0 && request.completedBoundaries.length === 0 // We don't need to check any partially completed segments because diff --git a/scripts/rollup/generate-inline-fizz-runtime.js b/scripts/rollup/generate-inline-fizz-runtime.js index ce613fdb8d..3d097f63e2 100644 --- a/scripts/rollup/generate-inline-fizz-runtime.js +++ b/scripts/rollup/generate-inline-fizz-runtime.js @@ -13,6 +13,10 @@ const inlineCodeStringsFilename = instructionDir + '/ReactDOMFizzInstructionSetInlineCodeStrings.js'; const config = [ + { + entry: 'ReactDOMFizzInlineShellTime.js', + exportName: 'markShellTime', + }, { entry: 'ReactDOMFizzInlineClientRenderBoundary.js', exportName: 'clientRenderBoundary', @@ -66,7 +70,7 @@ async function main() { }); }); - return `export const ${exportName} = ${JSON.stringify(code.trim())};`; + return `export const ${exportName} = ${JSON.stringify(code.trim().replace('\n', ''))};`; }) );