diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index ad0477232ca02..e1cd27b69bb23 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -238,6 +238,7 @@ const ACTIVITY_END_DATA = '/&'; const SUSPENSE_START_DATA = '$'; const SUSPENSE_END_DATA = '/$'; const SUSPENSE_PENDING_START_DATA = '$?'; +const SUSPENSE_QUEUED_START_DATA = '$~'; const SUSPENSE_FALLBACK_START_DATA = '$!'; const PREAMBLE_CONTRIBUTION_HTML = 'html'; const PREAMBLE_CONTRIBUTION_BODY = 'body'; @@ -245,7 +246,7 @@ const PREAMBLE_CONTRIBUTION_HEAD = 'head'; const FORM_STATE_IS_MATCHING = 'F!'; const FORM_STATE_IS_NOT_MATCHING = 'F'; -const DOCUMENT_READY_STATE_COMPLETE = 'complete'; +const DOCUMENT_READY_STATE_LOADING = 'loading'; const STYLE = 'style'; @@ -1084,6 +1085,7 @@ function clearHydrationBoundary( } else if ( data === SUSPENSE_START_DATA || data === SUSPENSE_PENDING_START_DATA || + data === SUSPENSE_QUEUED_START_DATA || data === SUSPENSE_FALLBACK_START_DATA || data === ACTIVITY_START_DATA ) { @@ -1205,6 +1207,7 @@ function hideOrUnhideDehydratedBoundary( } else if ( data === SUSPENSE_START_DATA || data === SUSPENSE_PENDING_START_DATA || + data === SUSPENSE_QUEUED_START_DATA || data === SUSPENSE_FALLBACK_START_DATA ) { depth++; @@ -3140,7 +3143,10 @@ export function canHydrateSuspenseInstance( } export function isSuspenseInstancePending(instance: SuspenseInstance): boolean { - return instance.data === SUSPENSE_PENDING_START_DATA; + return ( + instance.data === SUSPENSE_PENDING_START_DATA || + instance.data === SUSPENSE_QUEUED_START_DATA + ); } export function isSuspenseInstanceFallback( @@ -3149,7 +3155,7 @@ export function isSuspenseInstanceFallback( return ( instance.data === SUSPENSE_FALLBACK_START_DATA || (instance.data === SUSPENSE_PENDING_START_DATA && - instance.ownerDocument.readyState === DOCUMENT_READY_STATE_COMPLETE) + instance.ownerDocument.readyState !== DOCUMENT_READY_STATE_LOADING) ); } @@ -3192,7 +3198,11 @@ export function registerSuspenseInstanceRetry( callback: () => void, ) { const ownerDocument = instance.ownerDocument; - if ( + if (instance.data === SUSPENSE_QUEUED_START_DATA) { + // The Fizz runtime has already queued this boundary for reveal. We wait for it + // to be revealed and then retries. + instance._reactRetry = callback; + } else if ( // The Fizz runtime must have put this boundary into client render or complete // state after the render finished but before it committed. We need to call the // callback now rather than wait @@ -3200,7 +3210,7 @@ export function registerSuspenseInstanceRetry( // The boundary is still in pending status but the document has finished loading // before we could register the event handler that would have scheduled the retry // on load so we call teh callback now. - ownerDocument.readyState === DOCUMENT_READY_STATE_COMPLETE + ownerDocument.readyState !== DOCUMENT_READY_STATE_LOADING ) { callback(); } else { @@ -3255,18 +3265,19 @@ function getNextHydratable(node: ?Node) { break; } if (nodeType === COMMENT_NODE) { - const nodeData = (node: any).data; + const data = (node: any).data; if ( - nodeData === SUSPENSE_START_DATA || - nodeData === SUSPENSE_FALLBACK_START_DATA || - nodeData === SUSPENSE_PENDING_START_DATA || - nodeData === ACTIVITY_START_DATA || - nodeData === FORM_STATE_IS_MATCHING || - nodeData === FORM_STATE_IS_NOT_MATCHING + data === SUSPENSE_START_DATA || + data === SUSPENSE_FALLBACK_START_DATA || + data === SUSPENSE_PENDING_START_DATA || + data === SUSPENSE_QUEUED_START_DATA || + data === ACTIVITY_START_DATA || + data === FORM_STATE_IS_MATCHING || + data === FORM_STATE_IS_NOT_MATCHING ) { break; } - if (nodeData === SUSPENSE_END_DATA || nodeData === ACTIVITY_END_DATA) { + if (data === SUSPENSE_END_DATA || data === ACTIVITY_END_DATA) { return null; } } @@ -3494,6 +3505,7 @@ function getNextHydratableInstanceAfterHydrationBoundary( data === SUSPENSE_START_DATA || data === SUSPENSE_FALLBACK_START_DATA || data === SUSPENSE_PENDING_START_DATA || + data === SUSPENSE_QUEUED_START_DATA || data === ACTIVITY_START_DATA ) { depth++; @@ -3535,6 +3547,7 @@ export function getParentHydrationBoundary( data === SUSPENSE_START_DATA || data === SUSPENSE_FALLBACK_START_DATA || data === SUSPENSE_PENDING_START_DATA || + data === SUSPENSE_QUEUED_START_DATA || data === ACTIVITY_START_DATA ) { if (depth === 0) { diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 93b7b9ed9b071..a54a623bb3954 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); @@ -4482,14 +4516,14 @@ export function writeCompletedSegmentInstruction( } } +const completeBoundaryScriptFunctionOnly = stringToPrecomputedChunk( + completeBoundaryFunction, +); const completeBoundaryScript1Full = stringToPrecomputedChunk( completeBoundaryFunction + '$RC("', ); const completeBoundaryScript1Partial = stringToPrecomputedChunk('$RC("'); -const completeBoundaryWithStylesScript1FullBoth = stringToPrecomputedChunk( - completeBoundaryFunction + styleInsertionFunction + '$RR("', -); const completeBoundaryWithStylesScript1FullPartial = stringToPrecomputedChunk( styleInsertionFunction + '$RR("', ); @@ -4531,19 +4565,27 @@ export function writeCompletedBoundaryInstruction( writeChunk(destination, renderState.startInlineScript); writeChunk(destination, endOfStartTag); if (requiresStyleInsertion) { + if ( + (resumableState.instructions & SentClientRenderFunction) === + NothingSent + ) { + // The completeBoundaryWithStyles function depends on the client render function. + resumableState.instructions |= SentClientRenderFunction; + writeChunk(destination, clientRenderScriptFunctionOnly); + } if ( (resumableState.instructions & SentCompleteBoundaryFunction) === NothingSent ) { - resumableState.instructions |= - SentStyleInsertionFunction | SentCompleteBoundaryFunction; - writeChunk(destination, completeBoundaryWithStylesScript1FullBoth); - } else if ( + // The completeBoundaryWithStyles function depends on the complete boundary function. + resumableState.instructions |= SentCompleteBoundaryFunction; + writeChunk(destination, completeBoundaryScriptFunctionOnly); + } + if ( (resumableState.instructions & SentStyleInsertionFunction) === NothingSent ) { resumableState.instructions |= SentStyleInsertionFunction; - writeChunk(destination, completeBoundaryWithStylesScript1FullPartial); } else { writeChunk(destination, completeBoundaryWithStylesScript1Partial); @@ -4608,6 +4650,9 @@ export function writeCompletedBoundaryInstruction( return writeBootstrap(destination, renderState) && writeMore; } +const clientRenderScriptFunctionOnly = + stringToPrecomputedChunk(clientRenderFunction); + const clientRenderScript1Full = stringToPrecomputedChunk( clientRenderFunction + ';$RX("', ); @@ -5004,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, @@ -5029,15 +5089,10 @@ export function writePreambleStart( destination: Destination, 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 ( - enableFizzExternalRuntime && - !willFlushAllSegments && - renderState.externalRuntimeScript - ) { + if (enableFizzExternalRuntime && renderState.externalRuntimeScript) { // If the root segment is incomplete due to suspended tasks // (e.g. willFlushAllSegments = false) and we are using data // streaming format, ensure the external runtime is sent. 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 7a437c07c622b..470698b27c0c5 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(b,d,e){if(d=document.getElementById(d)){d.parentNode.removeChild(d);var a=document.getElementById(b);if(a){b=a.previousSibling;if(e)b.data="$!",a.setAttribute("data-dgst",e);else{e=b.parentNode;a=b.nextSibling;var f=0;do{if(a&&8===a.nodeType){var c=a.data;if("/$"===c||"/&"===c)if(0===f)break;else f--;else"$"!==c&&"$?"!==c&&"$!"!==c&&"&"!==c||f++}c=a.nextSibling;e.removeChild(a);a=c}while(a);for(;d.firstChild;)e.insertBefore(d.firstChild,a);b.data="$"}b._reactRetry&&b._reactRetry()}}};'; + '$RB=[];$RC=function(d,c){function m(){$RT=performance.now();var f=$RB;$RB=[];for(var e=0;e { 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: }); }); @@ -3581,6 +3587,9 @@ describe('ReactDOMFizzServer', () => { '' + + (gate(flags => flags.shouldUseFizzExternalRuntime) + ? '' + : '') + '', ); }); @@ -4495,7 +4504,8 @@ describe('ReactDOMFizzServer', () => { expect(document.getElementsByTagName('script').length).toEqual(1); }); - it('does not send the external runtime for static pages', async () => { + // @gate shouldUseFizzExternalRuntime + it('does (unfortunately) send the external runtime for static pages', async () => { await act(() => { const {pipe} = renderToPipeableStream( @@ -4509,11 +4519,11 @@ describe('ReactDOMFizzServer', () => { }); // no scripts should be sent - expect(document.getElementsByTagName('script').length).toEqual(0); + expect(document.getElementsByTagName('script').length).toEqual(1); // the html should be as-is expect(document.documentElement.innerHTML).toEqual( - '

hello world!

', + '

hello world!

', ); }); @@ -5311,7 +5321,9 @@ describe('ReactDOMFizzServer', () => { }); expect(container.innerHTML).toEqual( - '
helloworld, Foo!
', + (gate(flags => flags.shouldUseFizzExternalRuntime) + ? '' + : '') + '
helloworld, Foo!
', ); const errors = []; ReactDOMClient.hydrateRoot(container, , { @@ -5512,7 +5524,7 @@ describe('ReactDOMFizzServer', () => { pipe(writable); }); - expect(container.firstElementChild.outerHTML).toEqual( + expect(container.lastElementChild.outerHTML).toEqual( '
helloworld
', ); @@ -5550,7 +5562,7 @@ describe('ReactDOMFizzServer', () => { pipe(writable); }); - expect(container.firstElementChild.outerHTML).toEqual( + expect(container.lastElementChild.outerHTML).toEqual( '
helloworld
', ); @@ -5690,7 +5702,10 @@ describe('ReactDOMFizzServer', () => { }); expect(container.innerHTML).toEqual( - '
helloworldworldhelloworld
world
', + (gate(flags => flags.shouldUseFizzExternalRuntime) + ? '' + : '') + + '
helloworldworldhelloworld
world
', ); const errors = []; @@ -6493,7 +6508,11 @@ describe('ReactDOMFizzServer', () => { }); expect(document.documentElement.outerHTML).toEqual( - '', + '' + + (gate(flags => flags.shouldUseFizzExternalRuntime) + ? '' + : '') + + '', ); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js index 7eecb16cf82f6..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; @@ -1630,14 +1640,14 @@ describe('ReactDOMFizzStaticBrowser', () => { // We are mostly just trying to assert that no preload for our stylesheet was emitted // prior to sending the segment the stylesheet was for. This test is asserting this // because the boundary complete instruction is sent when we are writing the - const instructionIndex = result.indexOf('$RC'); + const instructionIndex = result.indexOf('$RX'); expect(instructionIndex > -1).toBe(true); - const slice = result.slice(0, instructionIndex + '$RC'.length); + const slice = result.slice(0, instructionIndex + '$RX'.length); expect(slice).toBe( '' + - 'hello' + - '' + + '' + + (gate(flags => flags.shouldUseFizzExternalRuntime) + ? '' + : '') + 'foo' + 'bar', '', @@ -729,7 +739,9 @@ describe('ReactDOMFloat', () => { }); expect( - Array.from(document.getElementsByTagName('script')).map(n => n.outerHTML), + Array.from(document.querySelectorAll('script[async]')).map( + n => n.outerHTML, + ), ).toEqual(['']); }); @@ -1265,9 +1277,7 @@ body { const suspenseInstance = boundaryTemplateInstance.previousSibling; expect(suspenseInstance.data).toEqual('$!'); - expect(boundaryTemplateInstance.dataset.dgst).toBe( - 'Resource failed to load', - ); + expect(boundaryTemplateInstance.dataset.dgst).toBe('CSS failed to load'); expect(getMeaningfulChildren(document)).toEqual( @@ -1313,7 +1323,7 @@ body { ); expect(errors).toEqual([ 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', - 'Resource failed to load', + 'CSS failed to load', ]); }); @@ -3611,6 +3621,7 @@ body { assertConsoleErrorDev([ "Hydration failed because the server rendered HTML didn't match the client.", ]); + jest.runAllTimers(); expect(getMeaningfulChildren(document)).toEqual( @@ -5204,6 +5215,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..7d14ccd628de2 100644 --- a/packages/react-markup/src/ReactFizzConfigMarkup.js +++ b/packages/react-markup/src/ReactFizzConfigMarkup.js @@ -208,14 +208,12 @@ 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 ); } @@ -224,6 +222,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..155bb7fe3ff3a 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -4835,14 +4835,7 @@ function flushPreamble( preambleSegments: Array>, ) { // The preamble is ready. - const willFlushAllSegments = - request.allPendingTasks === 0 && request.trackedPostpones === null; - writePreambleStart( - destination, - request.resumableState, - request.renderState, - willFlushAllSegments, - ); + writePreambleStart(destination, request.resumableState, request.renderState); for (let i = 0; i < preambleSegments.length; i++) { const segments = preambleSegments[i]; for (let j = 0; j < segments.length; j++) { @@ -5217,10 +5210,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 +5294,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', ''))};`; }) );