diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index bc3a7c36eb7db..371a1dd229430 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; } +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 1cbe1e222af91..44a81c64468e7 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 0000000000000..9e52a25aa7a54 --- /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 2481752425c46..845ea5b5666e3 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 97a1a7b80a99b..0653761df7594 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 2f63c9695b5d8..578b2bf916f61 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 444952dc58502..800dd46e0ad77 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 fe00f28b0240f..119c9885db783 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 f55d6f08043f4..65efe7c7e8935 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 ce613fdb8d039..3d097f63e216a 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', ''))};`; }) );