@samitouri / QOS-React / commits / ee7fee8f88

[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.

Sebastian Markbåge committed May 1, 2025 at 16:09 UTC ee7fee8f8875052afde53c5bfc8aedad43ff9d8e
13 files changed +220 -67
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+60 -11
@@ -81,6 +81,7 @@ import {
81 completeBoundaryWithStyles as styleInsertionFunction,
82 completeSegment as completeSegmentFunction,
83 formReplaying as formReplayingRuntime,
84 + markShellTime,
85 } from './fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings';
86
87 import {getValueDescriptorExpectingObjectForWarning} from '../shared/ReactDOMResourceValidation';
@@ -120,13 +121,14 @@ const ScriptStreamingFormat: StreamingFormat = 0;
121 const DataStreamingFormat: StreamingFormat = 1;
122
123 export type InstructionState = number;
123 -const NothingSent /* */ = 0b000000;
124 -const SentCompleteSegmentFunction /* */ = 0b000001;
125 -const SentCompleteBoundaryFunction /* */ = 0b000010;
126 -const SentClientRenderFunction /* */ = 0b000100;
127 -const SentStyleInsertionFunction /* */ = 0b001000;
128 -const SentFormReplayingRuntime /* */ = 0b010000;
129 -const SentCompletedShellId /* */ = 0b100000;
124 +const NothingSent /* */ = 0b0000000;
125 +const SentCompleteSegmentFunction /* */ = 0b0000001;
126 +const SentCompleteBoundaryFunction /* */ = 0b0000010;
127 +const SentClientRenderFunction /* */ = 0b0000100;
128 +const SentStyleInsertionFunction /* */ = 0b0001000;
129 +const SentFormReplayingRuntime /* */ = 0b0010000;
130 +const SentCompletedShellId /* */ = 0b0100000;
131 +const SentMarkShellTime /* */ = 0b1000000;
132
133 // Per request, global state that is not contextual to the rendering subtree.
134 // This cannot be resumed and therefore should only contain things that are
@@ -4107,21 +4109,53 @@ function writeBootstrap(
4109 return true;
4110 }
4111
4112 +const shellTimeRuntimeScript = stringToPrecomputedChunk(markShellTime);
4113 +
4114 +function writeShellTimeInstruction(
4115 + destination: Destination,
4116 + resumableState: ResumableState,
4117 + renderState: RenderState,
4118 +): boolean {
4119 + if (
4120 + enableFizzExternalRuntime &&
4121 + resumableState.streamingFormat !== ScriptStreamingFormat
4122 + ) {
4123 + // External runtime always tracks the shell time in the runtime.
4124 + return true;
4125 + }
4126 + if ((resumableState.instructions & SentMarkShellTime) !== NothingSent) {
4127 + // We already sent this instruction.
4128 + return true;
4129 + }
4130 + resumableState.instructions |= SentMarkShellTime;
4131 + writeChunk(destination, renderState.startInlineScript);
4132 + writeCompletedShellIdAttribute(destination, resumableState);
4133 + writeChunk(destination, endOfStartTag);
4134 + writeChunk(destination, shellTimeRuntimeScript);
4135 + return writeChunkAndReturn(destination, endInlineScript);
4136 +}
4137 +
4138 export function writeCompletedRoot(
4139 destination: Destination,
4140 resumableState: ResumableState,
4141 renderState: RenderState,
4142 + isComplete: boolean,
4143 ): boolean {
4144 + if (!isComplete) {
4145 + // If we're not already fully complete, we might complete another boundary. If so,
4146 + // we need to track the paint time of the shell so we know how much to throttle the reveal.
4147 + writeShellTimeInstruction(destination, resumableState, renderState);
4148 + }
4149 const preamble = renderState.preamble;
4150 if (preamble.htmlChunks || preamble.headChunks) {
4151 // If we rendered the whole document, then we emitted a rel="expect" that needs a
4152 // matching target. Normally we use one of the bootstrap scripts for this but if
4153 // there are none, then we need to emit a tag to complete the shell.
4154 if ((resumableState.instructions & SentCompletedShellId) === NothingSent) {
4121 - const bootstrapChunks = renderState.bootstrapChunks;
4122 - bootstrapChunks.push(startChunkForTag('template'));
4123 - pushCompletedShellIdAttribute(bootstrapChunks, resumableState);
4124 - bootstrapChunks.push(endOfStartTag, endChunkForTag('template'));
4155 + writeChunk(destination, startChunkForTag('template'));
4156 + writeCompletedShellIdAttribute(destination, resumableState);
4157 + writeChunk(destination, endOfStartTag);
4158 + writeChunk(destination, endChunkForTag('template'));
4159 }
4160 }
4161 return writeBootstrap(destination, renderState);
@@ -5015,6 +5049,21 @@ function writeBlockingRenderInstruction(
5049
5050 const completedShellIdAttributeStart = stringToPrecomputedChunk(' id="');
5051
5052 +function writeCompletedShellIdAttribute(
5053 + destination: Destination,
5054 + resumableState: ResumableState,
5055 +): void {
5056 + if ((resumableState.instructions & SentCompletedShellId) !== NothingSent) {
5057 + return;
5058 + }
5059 + resumableState.instructions |= SentCompletedShellId;
5060 + const idPrefix = resumableState.idPrefix;
5061 + const shellId = '\u00AB' + idPrefix + 'R\u00BB';
5062 + writeChunk(destination, completedShellIdAttributeStart);
5063 + writeChunk(destination, stringToChunk(escapeTextForBrowser(shellId)));
5064 + writeChunk(destination, attributeEnd);
5065 +}
5066 +
5067 function pushCompletedShellIdAttribute(
5068 target: Array<Chunk | PrecomputedChunk>,
5069 resumableState: ResumableState,
packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineCompleteBoundary.js
+2
@@ -2,4 +2,6 @@ import {completeBoundary} from './ReactDOMFizzInstructionSetShared';
2
3 // This is a string so Closure's advanced compilation mode doesn't mangle it.
4 // eslint-disable-next-line dot-notation
5 +window['$RB'] = [];
6 +// eslint-disable-next-line dot-notation
7 window['$RC'] = completeBoundary;
packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineShellTime.js new
+5
@@ -0,0 +1,5 @@
1 +// Track the paint time of the shell
2 +requestAnimationFrame(() => {
3 + // eslint-disable-next-line dot-notation
4 + window['$RT'] = performance.now();
5 +});
packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetExternalRuntime.js
+17
@@ -13,9 +13,26 @@ import {
13 // This is a string so Closure's advanced compilation mode doesn't mangle it.
14 // These will be renamed to local references by the external-runtime-plugin.
15 window['$RM'] = new Map();
16 +window['$RB'] = [];
17 window['$RX'] = clientRenderBoundary;
18 window['$RC'] = completeBoundary;
19 window['$RR'] = completeBoundaryWithStyles;
20 window['$RS'] = completeSegment;
21
22 listenToFormSubmissionsForReplaying();
23 +
24 +// Track the paint time of the shell.
25 +const entries = performance.getEntriesByType
26 + ? performance.getEntriesByType('paint')
27 + : [];
28 +if (entries.length > 0) {
29 + // We might have already painted before this external runtime loaded. In that case we
30 + // try to get the first paint from the performance metrics to avoid delaying further
31 + // than necessary.
32 + window['$RT'] = entries[0].startTime;
33 +} else {
34 + // Otherwise we wait for the next rAF for it.
35 + requestAnimationFrame(() => {
36 + window['$RT'] = performance.now();
37 + });
38 +}
packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js
+4 -2
@@ -1,12 +1,14 @@
1 // This is a generated file. The source files are in react-dom-bindings/src/server/fizz-instruction-set.
2 // The build script is at scripts/rollup/generate-inline-fizz-runtime.js.
3 // Run `yarn generate-inline-fizz-runtime` to generate.
4 +export const markShellTime =
5 + 'requestAnimationFrame(function(){$RT=performance.now()});';
6 export const clientRenderBoundary =
7 '$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())};';
8 export const completeBoundary =
7 - '$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()}};';
9 + '$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())};';
10 export const completeBoundaryWithStyles =
9 - '$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"))};';
11 + '$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"))};';
12 export const completeSegment =
13 '$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)};';
14 export const formReplaying =
packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js
+76 -43
@@ -47,9 +47,11 @@ export function clientRenderBoundary(
47 }
48 }
49
50 +const FALLBACK_THROTTLE_MS = 300;
51 +
52 export function completeBoundary(suspenseBoundaryID, contentID) {
51 - const contentNode = document.getElementById(contentID);
52 - if (!contentNode) {
53 + const contentNodeOuter = document.getElementById(contentID);
54 + if (!contentNodeOuter) {
55 // If the client has failed hydration we may have already deleted the streaming
56 // segments. The server may also have emitted a complete instruction but cancelled
57 // the segment. Regardless we can ignore this case.
@@ -57,62 +59,93 @@ export function completeBoundary(suspenseBoundaryID, contentID) {
59 }
60 // We'll detach the content node so that regardless of what happens next we don't leave in the tree.
61 // This might also help by not causing recalcing each time we move a child from here to the target.
60 - contentNode.parentNode.removeChild(contentNode);
62 + contentNodeOuter.parentNode.removeChild(contentNodeOuter);
63
64 // Find the fallback's first element.
63 - const suspenseIdNode = document.getElementById(suspenseBoundaryID);
64 - if (!suspenseIdNode) {
65 + const suspenseIdNodeOuter = document.getElementById(suspenseBoundaryID);
66 + if (!suspenseIdNodeOuter) {
67 // The user must have already navigated away from this tree.
68 // E.g. because the parent was hydrated. That's fine there's nothing to do
69 // but we have to make sure that we already deleted the container node.
70 return;
71 }
70 - // Find the boundary around the fallback. This is always the previous node.
71 - const suspenseNode = suspenseIdNode.previousSibling;
72
73 - // Clear all the existing children. This is complicated because
74 - // there can be embedded Suspense boundaries in the fallback.
75 - // This is similar to clearSuspenseBoundary in ReactFiberConfigDOM.
76 - // TODO: We could avoid this if we never emitted suspense boundaries in fallback trees.
77 - // They never hydrate anyway. However, currently we support incrementally loading the fallback.
78 - const parentInstance = suspenseNode.parentNode;
79 - let node = suspenseNode.nextSibling;
80 - let depth = 0;
81 - do {
82 - if (node && node.nodeType === COMMENT_NODE) {
83 - const data = node.data;
84 - if (data === SUSPENSE_END_DATA || data === ACTIVITY_END_DATA) {
85 - if (depth === 0) {
86 - break;
87 - } else {
88 - depth--;
89 - }
90 - } else if (
91 - data === SUSPENSE_START_DATA ||
92 - data === SUSPENSE_PENDING_START_DATA ||
93 - data === SUSPENSE_FALLBACK_START_DATA ||
94 - data === ACTIVITY_START_DATA
95 - ) {
96 - depth++;
73 + function revealCompletedBoundaries() {
74 + window['$RT'] = performance.now();
75 + const batch = window['$RB'];
76 + window['$RB'] = [];
77 + for (let i = 0; i < batch.length; i += 2) {
78 + const suspenseIdNode = batch[i];
79 + const contentNode = batch[i + 1];
80 +
81 + // Clear all the existing children. This is complicated because
82 + // there can be embedded Suspense boundaries in the fallback.
83 + // This is similar to clearSuspenseBoundary in ReactFiberConfigDOM.
84 + // TODO: We could avoid this if we never emitted suspense boundaries in fallback trees.
85 + // They never hydrate anyway. However, currently we support incrementally loading the fallback.
86 + const parentInstance = suspenseIdNode.parentNode;
87 + if (!parentInstance) {
88 + // We may have client-rendered this boundary already. Skip it.
89 + continue;
90 }
98 - }
91
100 - const nextNode = node.nextSibling;
101 - parentInstance.removeChild(node);
102 - node = nextNode;
103 - } while (node);
92 + // Find the boundary around the fallback. This is always the previous node.
93 + const suspenseNode = suspenseIdNode.previousSibling;
94
105 - const endOfBoundary = node;
95 + let node = suspenseIdNode;
96 + let depth = 0;
97 + do {
98 + if (node && node.nodeType === COMMENT_NODE) {
99 + const data = node.data;
100 + if (data === SUSPENSE_END_DATA || data === ACTIVITY_END_DATA) {
101 + if (depth === 0) {
102 + break;
103 + } else {
104 + depth--;
105 + }
106 + } else if (
107 + data === SUSPENSE_START_DATA ||
108 + data === SUSPENSE_PENDING_START_DATA ||
109 + data === SUSPENSE_FALLBACK_START_DATA ||
110 + data === ACTIVITY_START_DATA
111 + ) {
112 + depth++;
113 + }
114 + }
115 +
116 + const nextNode = node.nextSibling;
117 + parentInstance.removeChild(node);
118 + node = nextNode;
119 + } while (node);
120 +
121 + const endOfBoundary = node;
122 +
123 + // Insert all the children from the contentNode between the start and end of suspense boundary.
124 + while (contentNode.firstChild) {
125 + parentInstance.insertBefore(contentNode.firstChild, endOfBoundary);
126 + }
127
107 - // Insert all the children from the contentNode between the start and end of suspense boundary.
108 - while (contentNode.firstChild) {
109 - parentInstance.insertBefore(contentNode.firstChild, endOfBoundary);
128 + suspenseNode.data = SUSPENSE_START_DATA;
129 + if (suspenseNode['_reactRetry']) {
130 + suspenseNode['_reactRetry']();
131 + }
132 + }
133 }
134
112 - suspenseNode.data = SUSPENSE_START_DATA;
135 + // Queue this boundary for the next batch
136 + window['$RB'].push(suspenseIdNodeOuter, contentNodeOuter);
137
114 - if (suspenseNode['_reactRetry']) {
115 - suspenseNode['_reactRetry']();
138 + if (window['$RB'].length === 2) {
139 + // This is the first time we've pushed to the batch. We need to schedule a callback
140 + // to flush the batch. This is delayed by the throttle heuristic.
141 + const globalMostRecentFallbackTime =
142 + typeof window['$RT'] !== 'number' ? 0 : window['$RT'];
143 + const msUntilTimeout =
144 + globalMostRecentFallbackTime + FALLBACK_THROTTLE_MS - performance.now();
145 + // We always schedule the flush in a timer even if it's very low or negative to allow
146 + // for multiple completeBoundary calls that are already queued to have a chance to
147 + // make the batch.
148 + setTimeout(revealCompletedBoundaries, msUntilTimeout);
149 }
150 }
151
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+9 -3
@@ -83,6 +83,9 @@ describe('ReactDOMFizzServer', () => {
83 global.Node = global.window.Node;
84 global.addEventListener = global.window.addEventListener;
85 global.MutationObserver = global.window.MutationObserver;
86 + // The Fizz runtime assumes requestAnimationFrame exists so we need to polyfill it.
87 + global.requestAnimationFrame = global.window.requestAnimationFrame = cb =>
88 + setTimeout(cb);
89 container = document.getElementById('container');
90
91 Scheduler = require('scheduler');
@@ -206,6 +209,7 @@ describe('ReactDOMFizzServer', () => {
209 buffer = '';
210
211 if (!bufferedContent) {
212 + jest.runAllTimers();
213 return;
214 }
215
@@ -314,6 +318,8 @@ describe('ReactDOMFizzServer', () => {
318 div.innerHTML = bufferedContent;
319 await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce);
320 }
321 + // Let throttled boundaries reveal
322 + jest.runAllTimers();
323 }
324
325 function resolveText(text) {
@@ -602,12 +608,12 @@ describe('ReactDOMFizzServer', () => {
608 ]);
609
610 // check that there are 6 scripts with a matching nonce:
605 - // The runtime script, an inline bootstrap script, two bootstrap scripts and two bootstrap modules
611 + // The runtime script or initial paint time, an inline bootstrap script, two bootstrap scripts and two bootstrap modules
612 expect(
613 Array.from(container.getElementsByTagName('script')).filter(
614 node => node.getAttribute('nonce') === CSPnonce,
615 ).length,
610 - ).toEqual(gate(flags => flags.shouldUseFizzExternalRuntime) ? 6 : 5);
616 + ).toEqual(6);
617
618 await act(() => {
619 resolve({default: Text});
@@ -836,7 +842,7 @@ describe('ReactDOMFizzServer', () => {
842 container.childNodes,
843 renderOptions.unstable_externalRuntimeSrc,
844 ).length,
839 - ).toBe(1);
845 + ).toBe(gate(flags => flags.shouldUseFizzExternalRuntime) ? 1 : 2);
846 await act(() => {
847 resolveElement({default: <Text text="Hello" />});
848 });
packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js
+15 -5
@@ -38,6 +38,9 @@ describe('ReactDOMFizzStaticBrowser', () => {
38 jest.resetModules();
39 JSDOM = require('jsdom').JSDOM;
40
41 + // We need the mocked version of setTimeout inside the document.
42 + window.setTimeout = setTimeout;
43 +
44 Scheduler = require('scheduler');
45 patchMessageChannel(Scheduler);
46 act = require('internal-test-utils').act;
@@ -133,13 +136,18 @@ describe('ReactDOMFizzStaticBrowser', () => {
136 const temp = document.createElement('div');
137 temp.innerHTML = result;
138 await insertNodesAndExecuteScripts(temp, container, null);
139 + jest.runAllTimers();
140 }
141
142 async function readIntoNewDocument(stream) {
143 const content = await readContent(stream);
140 - const jsdom = new JSDOM(content, {
141 - runScripts: 'dangerously',
142 - });
144 + const jsdom = new JSDOM(
145 + // The Fizz runtime assumes requestAnimationFrame exists so we need to polyfill it.
146 + '<script>window.requestAnimationFrame = setTimeout;</script>' + content,
147 + {
148 + runScripts: 'dangerously',
149 + },
150 + );
151 const originalWindow = global.window;
152 const originalDocument = global.document;
153 const originalNavigator = global.navigator;
@@ -167,6 +175,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
175 const temp = document.createElement('div');
176 temp.innerHTML = content;
177 await insertNodesAndExecuteScripts(temp, document.body, null);
178 + jest.runAllTimers();
179 }
180
181 it('should call prerender', async () => {
@@ -980,6 +989,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
989 // Wait for the instruction microtasks to flush.
990 await 0;
991 await 0;
992 + jest.runAllTimers();
993
994 expect(getVisibleChildren(container)).toEqual([
995 <link href="example.com" rel="preconnect" />,
@@ -1611,7 +1621,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
1621
1622 expect(result).toBe(
1623 '<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head>' +
1614 - '<body>hello<!--$?--><template id="B:1"></template><!--/$--><template id="«R»"></template>',
1624 + '<body>hello<!--$?--><template id="B:1"></template><!--/$--><script id="«R»">requestAnimationFrame(function(){$RT=performance.now()});</script>',
1625 );
1626
1627 await 1;
@@ -1636,7 +1646,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
1646
1647 expect(slice).toBe(
1648 '<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head>' +
1639 - '<body>hello<!--$?--><template id="B:1"></template><!--/$--><template id="«R»"></template>' +
1649 + '<body>hello<!--$?--><template id="B:1"></template><!--/$--><script id="«R»">requestAnimationFrame(function(){$RT=performance.now()});</script>' +
1650 '<div hidden id="S:1">world<!-- --></div><script>$RX',
1651 );
1652 });
packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+15 -1
@@ -63,6 +63,9 @@ describe('ReactDOMFloat', () => {
63 global.Node = global.window.Node;
64 global.addEventListener = global.window.addEventListener;
65 global.MutationObserver = global.window.MutationObserver;
66 + // The Fizz runtime assumes requestAnimationFrame exists so we need to polyfill it.
67 + global.requestAnimationFrame = global.window.requestAnimationFrame = cb =>
68 + setTimeout(cb);
69 container = document.getElementById('container');
70
71 React = require('react');
@@ -122,6 +125,7 @@ describe('ReactDOMFloat', () => {
125 buffer = '';
126
127 if (!bufferedContent) {
128 + jest.runAllTimers();
129 return;
130 }
131
@@ -230,6 +234,9 @@ describe('ReactDOMFloat', () => {
234 div.innerHTML = bufferedContent;
235 await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce);
236 }
237 + await 0;
238 + // Let throttled boundaries reveal
239 + jest.runAllTimers();
240 }
241
242 function getMeaningfulChildren(element) {
@@ -729,7 +736,9 @@ describe('ReactDOMFloat', () => {
736 });
737
738 expect(
732 - Array.from(document.getElementsByTagName('script')).map(n => n.outerHTML),
739 + Array.from(document.querySelectorAll('script[async]')).map(
740 + n => n.outerHTML,
741 + ),
742 ).toEqual(['<script src="src-of-external-runtime" async=""></script>']);
743 });
744
@@ -3609,6 +3618,7 @@ body {
3618 assertConsoleErrorDev([
3619 "Hydration failed because the server rendered HTML didn't match the client.",
3620 ]);
3621 + jest.runAllTimers();
3622
3623 expect(getMeaningfulChildren(document)).toEqual(
3624 <html>
@@ -5202,6 +5212,10 @@ body {
5212 </html>,
5213 );
5214 loadStylesheets();
5215 + // Let the styles flush and then flush the boundaries
5216 + await 0;
5217 + await 0;
5218 + jest.runAllTimers();
5219 assertLog([
5220 'load stylesheet: shell preinit/shell',
5221 'load stylesheet: shell/shell preinit',
packages/react-markup/src/ReactFizzConfigMarkup.js
+1
@@ -224,6 +224,7 @@ export function writeCompletedRoot(
224 destination: Destination,
225 resumableState: ResumableState,
226 renderState: RenderState,
227 + isComplete: boolean,
228 ): boolean {
229 // Markup doesn't have any bootstrap scripts nor shell completions.
230 return true;
packages/react-noop-renderer/src/ReactNoopServer.js
+3
@@ -55,6 +55,7 @@ type Destination = {
55 stack: Array<Segment | Instance | SuspenseInstance>,
56 };
57
58 +type ResumableState = null;
59 type RenderState = null;
60 type HoistableState = null;
61 type PreambleState = null;
@@ -153,7 +154,9 @@ const ReactNoopServer = ReactFizzServer({
154
155 writeCompletedRoot(
156 destination: Destination,
157 + resumableState: ResumableState,
158 renderState: RenderState,
159 + isComplete: boolean,
160 ): boolean {
161 return true;
162 },
packages/react-server/src/ReactFizzServer.js
+8 -1
@@ -5217,10 +5217,18 @@ function flushCompletedQueues(
5217 );
5218 flushSegment(request, destination, completedRootSegment, null);
5219 request.completedRootSegment = null;
5220 + const isComplete =
5221 + request.allPendingTasks === 0 &&
5222 + request.clientRenderedBoundaries.length === 0 &&
5223 + request.completedBoundaries.length === 0 &&
5224 + (request.trackedPostpones === null ||
5225 + (request.trackedPostpones.rootNodes.length === 0 &&
5226 + request.trackedPostpones.rootSlots === null));
5227 writeCompletedRoot(
5228 destination,
5229 request.resumableState,
5230 request.renderState,
5231 + isComplete,
5232 );
5233 }
5234
@@ -5293,7 +5301,6 @@ function flushCompletedQueues(
5301 } finally {
5302 if (
5303 request.allPendingTasks === 0 &&
5296 - request.pingedTasks.length === 0 &&
5304 request.clientRenderedBoundaries.length === 0 &&
5305 request.completedBoundaries.length === 0
5306 // We don't need to check any partially completed segments because
scripts/rollup/generate-inline-fizz-runtime.js
+5 -1
@@ -13,6 +13,10 @@ const inlineCodeStringsFilename =
13 instructionDir + '/ReactDOMFizzInstructionSetInlineCodeStrings.js';
14
15 const config = [
16 + {
17 + entry: 'ReactDOMFizzInlineShellTime.js',
18 + exportName: 'markShellTime',
19 + },
20 {
21 entry: 'ReactDOMFizzInlineClientRenderBoundary.js',
22 exportName: 'clientRenderBoundary',
@@ -66,7 +70,7 @@ async function main() {
70 });
71 });
72
69 - return `export const ${exportName} = ${JSON.stringify(code.trim())};`;
73 + return `export const ${exportName} = ${JSON.stringify(code.trim().replace('\n', ''))};`;
74 })
75 );
76