[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.
This commit is contained in:
Sebastian Markbåge
2025-05-01 16:09:37 -04:00
committed by GitHub
parent ee077b6ccd
commit ee7fee8f88
13 changed files with 222 additions and 69 deletions
+61 -12
View File
@@ -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<Chunk | PrecomputedChunk>,
resumableState: ResumableState,
@@ -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;
@@ -0,0 +1,5 @@
// Track the paint time of the shell
requestAnimationFrame(() => {
// eslint-disable-next-line dot-notation
window['$RT'] = performance.now();
});
@@ -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();
});
}
@@ -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<f.length;d+=2){var a=f[d],l=f[d+1],g=a.parentNode;if(g){var h=a.previousSibling,k=0;do{if(a&&8===a.nodeType){var b=a.data;if("/$"===b||"/&"===b)if(0===k)break;else k--;else"$"!==b&&"$?"!==b&&"$!"!==b&&"&"!==b||k++}b=a.nextSibling;g.removeChild(a);a=b}while(a);for(;l.firstChild;)g.insertBefore(l.firstChild,a);h.data="$";h._reactRetry&&h._reactRetry()}}}if(c=document.getElementById(c))if(c.parentNode.removeChild(c),e=\ndocument.getElementById(e))$RB.push(e,c),2===$RB.length&&setTimeout(m,("number"!==typeof $RT?0:$RT)+300-performance.now())};';
export const completeBoundaryWithStyles =
'$RM=new Map;\n$RR=function(r,v,w){function t(n){this._p=null;n()}for(var p=new Map,q=document,g,b,h=q.querySelectorAll("link[data-precedence],style[data-precedence]"),u=[],k=0;b=h[k++];)"not all"===b.getAttribute("media")?u.push(b):("LINK"===b.tagName&&$RM.set(b.getAttribute("href"),b),p.set(b.dataset.precedence,g=b));b=0;h=[];var l,a;for(k=!0;;){if(k){var e=w[b++];if(!e){k=!1;b=0;continue}var c=!1,m=0;var d=e[m++];if(a=$RM.get(d)){var f=a._p;c=!0}else{a=q.createElement("link");a.href=d;a.rel=\n"stylesheet";for(a.dataset.precedence=l=e[m++];f=e[m++];)a.setAttribute(f,e[m++]);f=a._p=new Promise(function(n,x){a.onload=t.bind(a,n);a.onerror=t.bind(a,x)});$RM.set(d,a)}d=a.getAttribute("media");!f||d&&!matchMedia(d).matches||h.push(f);if(c)continue}else{a=u[b++];if(!a)break;l=a.getAttribute("data-precedence");a.removeAttribute("media")}c=p.get(l)||g;c===g&&(g=a);p.set(l,a);c?c.parentNode.insertBefore(a,c.nextSibling):(c=q.head,c.insertBefore(a,c.firstChild))}Promise.all(h).then($RC.bind(null,\nr,v),$RX.bind(null,r,"CSS failed to load"))};';
'$RM=new Map;$RR=function(r,v,w){function t(n){this._p=null;n()}for(var p=new Map,q=document,g,b,h=q.querySelectorAll("link[data-precedence],style[data-precedence]"),u=[],k=0;b=h[k++];)"not all"===b.getAttribute("media")?u.push(b):("LINK"===b.tagName&&$RM.set(b.getAttribute("href"),b),p.set(b.dataset.precedence,g=b));b=0;h=[];var l,a;for(k=!0;;){if(k){var e=w[b++];if(!e){k=!1;b=0;continue}var c=!1,m=0;var d=e[m++];if(a=$RM.get(d)){var f=a._p;c=!0}else{a=q.createElement("link");a.href=d;a.rel=\n"stylesheet";for(a.dataset.precedence=l=e[m++];f=e[m++];)a.setAttribute(f,e[m++]);f=a._p=new Promise(function(n,x){a.onload=t.bind(a,n);a.onerror=t.bind(a,x)});$RM.set(d,a)}d=a.getAttribute("media");!f||d&&!matchMedia(d).matches||h.push(f);if(c)continue}else{a=u[b++];if(!a)break;l=a.getAttribute("data-precedence");a.removeAttribute("media")}c=p.get(l)||g;c===g&&(g=a);p.set(l,a);c?c.parentNode.insertBefore(a,c.nextSibling):(c=q.head,c.insertBefore(a,c.firstChild))}Promise.all(h).then($RC.bind(null,\nr,v),$RX.bind(null,r,"CSS failed to load"))};';
export const completeSegment =
'$RS=function(a,b){a=document.getElementById(a);b=document.getElementById(b);for(a.parentNode.removeChild(a);a.firstChild;)b.parentNode.insertBefore(a.firstChild,b);b.parentNode.removeChild(b)};';
export const formReplaying =
@@ -47,9 +47,11 @@ export function clientRenderBoundary(
}
}
const FALLBACK_THROTTLE_MS = 300;
export function completeBoundary(suspenseBoundaryID, contentID) {
const contentNode = document.getElementById(contentID);
if (!contentNode) {
const contentNodeOuter = document.getElementById(contentID);
if (!contentNodeOuter) {
// If the client has failed hydration we may have already deleted the streaming
// segments. The server may also have emitted a complete instruction but cancelled
// the segment. Regardless we can ignore this case.
@@ -57,62 +59,93 @@ export function completeBoundary(suspenseBoundaryID, contentID) {
}
// We'll detach the content node so that regardless of what happens next we don't leave in the tree.
// This might also help by not causing recalcing each time we move a child from here to the target.
contentNode.parentNode.removeChild(contentNode);
contentNodeOuter.parentNode.removeChild(contentNodeOuter);
// Find the fallback's first element.
const suspenseIdNode = document.getElementById(suspenseBoundaryID);
if (!suspenseIdNode) {
const suspenseIdNodeOuter = document.getElementById(suspenseBoundaryID);
if (!suspenseIdNodeOuter) {
// The user must have already navigated away from this tree.
// E.g. because the parent was hydrated. That's fine there's nothing to do
// but we have to make sure that we already deleted the container node.
return;
}
// Find the boundary around the fallback. This is always the previous node.
const suspenseNode = suspenseIdNode.previousSibling;
// Clear all the existing children. This is complicated because
// there can be embedded Suspense boundaries in the fallback.
// This is similar to clearSuspenseBoundary in ReactFiberConfigDOM.
// TODO: We could avoid this if we never emitted suspense boundaries in fallback trees.
// They never hydrate anyway. However, currently we support incrementally loading the fallback.
const parentInstance = suspenseNode.parentNode;
let node = suspenseNode.nextSibling;
let depth = 0;
do {
if (node && node.nodeType === COMMENT_NODE) {
const data = node.data;
if (data === SUSPENSE_END_DATA || data === ACTIVITY_END_DATA) {
if (depth === 0) {
break;
} else {
depth--;
function revealCompletedBoundaries() {
window['$RT'] = performance.now();
const batch = window['$RB'];
window['$RB'] = [];
for (let i = 0; i < batch.length; i += 2) {
const suspenseIdNode = batch[i];
const contentNode = batch[i + 1];
// Clear all the existing children. This is complicated because
// there can be embedded Suspense boundaries in the fallback.
// This is similar to clearSuspenseBoundary in ReactFiberConfigDOM.
// TODO: We could avoid this if we never emitted suspense boundaries in fallback trees.
// They never hydrate anyway. However, currently we support incrementally loading the fallback.
const parentInstance = suspenseIdNode.parentNode;
if (!parentInstance) {
// We may have client-rendered this boundary already. Skip it.
continue;
}
// Find the boundary around the fallback. This is always the previous node.
const suspenseNode = suspenseIdNode.previousSibling;
let node = suspenseIdNode;
let depth = 0;
do {
if (node && node.nodeType === COMMENT_NODE) {
const data = node.data;
if (data === SUSPENSE_END_DATA || data === ACTIVITY_END_DATA) {
if (depth === 0) {
break;
} else {
depth--;
}
} else if (
data === SUSPENSE_START_DATA ||
data === SUSPENSE_PENDING_START_DATA ||
data === SUSPENSE_FALLBACK_START_DATA ||
data === ACTIVITY_START_DATA
) {
depth++;
}
}
} else if (
data === SUSPENSE_START_DATA ||
data === SUSPENSE_PENDING_START_DATA ||
data === SUSPENSE_FALLBACK_START_DATA ||
data === ACTIVITY_START_DATA
) {
depth++;
const nextNode = node.nextSibling;
parentInstance.removeChild(node);
node = nextNode;
} while (node);
const endOfBoundary = node;
// Insert all the children from the contentNode between the start and end of suspense boundary.
while (contentNode.firstChild) {
parentInstance.insertBefore(contentNode.firstChild, endOfBoundary);
}
suspenseNode.data = SUSPENSE_START_DATA;
if (suspenseNode['_reactRetry']) {
suspenseNode['_reactRetry']();
}
}
const nextNode = node.nextSibling;
parentInstance.removeChild(node);
node = nextNode;
} while (node);
const endOfBoundary = node;
// Insert all the children from the contentNode between the start and end of suspense boundary.
while (contentNode.firstChild) {
parentInstance.insertBefore(contentNode.firstChild, endOfBoundary);
}
suspenseNode.data = SUSPENSE_START_DATA;
// Queue this boundary for the next batch
window['$RB'].push(suspenseIdNodeOuter, contentNodeOuter);
if (suspenseNode['_reactRetry']) {
suspenseNode['_reactRetry']();
if (window['$RB'].length === 2) {
// This is the first time we've pushed to the batch. We need to schedule a callback
// to flush the batch. This is delayed by the throttle heuristic.
const globalMostRecentFallbackTime =
typeof window['$RT'] !== 'number' ? 0 : window['$RT'];
const msUntilTimeout =
globalMostRecentFallbackTime + FALLBACK_THROTTLE_MS - performance.now();
// We always schedule the flush in a timer even if it's very low or negative to allow
// for multiple completeBoundary calls that are already queued to have a chance to
// make the batch.
setTimeout(revealCompletedBoundaries, msUntilTimeout);
}
}
@@ -83,6 +83,9 @@ describe('ReactDOMFizzServer', () => {
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: <Text text="Hello" />});
});
@@ -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.
'<script>window.requestAnimationFrame = setTimeout;</script>' + 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([
<link href="example.com" rel="preconnect" />,
@@ -1611,7 +1621,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
expect(result).toBe(
'<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head>' +
'<body>hello<!--$?--><template id="B:1"></template><!--/$--><template id="«R»"></template>',
'<body>hello<!--$?--><template id="B:1"></template><!--/$--><script id="«R»">requestAnimationFrame(function(){$RT=performance.now()});</script>',
);
await 1;
@@ -1636,7 +1646,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
expect(slice).toBe(
'<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head>' +
'<body>hello<!--$?--><template id="B:1"></template><!--/$--><template id="«R»"></template>' +
'<body>hello<!--$?--><template id="B:1"></template><!--/$--><script id="«R»">requestAnimationFrame(function(){$RT=performance.now()});</script>' +
'<div hidden id="S:1">world<!-- --></div><script>$RX',
);
});
+15 -1
View File
@@ -63,6 +63,9 @@ describe('ReactDOMFloat', () => {
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');
React = require('react');
@@ -122,6 +125,7 @@ describe('ReactDOMFloat', () => {
buffer = '';
if (!bufferedContent) {
jest.runAllTimers();
return;
}
@@ -230,6 +234,9 @@ describe('ReactDOMFloat', () => {
div.innerHTML = bufferedContent;
await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce);
}
await 0;
// Let throttled boundaries reveal
jest.runAllTimers();
}
function getMeaningfulChildren(element) {
@@ -729,7 +736,9 @@ describe('ReactDOMFloat', () => {
});
expect(
Array.from(document.getElementsByTagName('script')).map(n => n.outerHTML),
Array.from(document.querySelectorAll('script[async]')).map(
n => n.outerHTML,
),
).toEqual(['<script src="src-of-external-runtime" async=""></script>']);
});
@@ -3609,6 +3618,7 @@ body {
assertConsoleErrorDev([
"Hydration failed because the server rendered HTML didn't match the client.",
]);
jest.runAllTimers();
expect(getMeaningfulChildren(document)).toEqual(
<html>
@@ -5202,6 +5212,10 @@ body {
</html>,
);
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',
+1
View File
@@ -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;
+3
View File
@@ -55,6 +55,7 @@ type Destination = {
stack: Array<Segment | Instance | SuspenseInstance>,
};
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;
},
+8 -1
View File
@@ -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
@@ -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', ''))};`;
})
);