Skip to content

Commit ee7fee8

Browse files
authored
[Fizz] Batch Suspense Boundary Reveal with Throttle (facebook#33076)
Stacked on facebook#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.
1 parent ee077b6 commit ee7fee8

13 files changed

+220
-67
lines changed

packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js

+60-11
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import {
8181
completeBoundaryWithStyles as styleInsertionFunction,
8282
completeSegment as completeSegmentFunction,
8383
formReplaying as formReplayingRuntime,
84+
markShellTime,
8485
} from './fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings';
8586

8687
import {getValueDescriptorExpectingObjectForWarning} from '../shared/ReactDOMResourceValidation';
@@ -120,13 +121,14 @@ const ScriptStreamingFormat: StreamingFormat = 0;
120121
const DataStreamingFormat: StreamingFormat = 1;
121122

122123
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;
130132

131133
// Per request, global state that is not contextual to the rendering subtree.
132134
// This cannot be resumed and therefore should only contain things that are
@@ -4107,21 +4109,53 @@ function writeBootstrap(
41074109
return true;
41084110
}
41094111

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+
41104138
export function writeCompletedRoot(
41114139
destination: Destination,
41124140
resumableState: ResumableState,
41134141
renderState: RenderState,
4142+
isComplete: boolean,
41144143
): 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+
}
41154149
const preamble = renderState.preamble;
41164150
if (preamble.htmlChunks || preamble.headChunks) {
41174151
// If we rendered the whole document, then we emitted a rel="expect" that needs a
41184152
// matching target. Normally we use one of the bootstrap scripts for this but if
41194153
// there are none, then we need to emit a tag to complete the shell.
41204154
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'));
41254159
}
41264160
}
41274161
return writeBootstrap(destination, renderState);
@@ -5015,6 +5049,21 @@ function writeBlockingRenderInstruction(
50155049

50165050
const completedShellIdAttributeStart = stringToPrecomputedChunk(' id="');
50175051

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+
50185067
function pushCompletedShellIdAttribute(
50195068
target: Array<Chunk | PrecomputedChunk>,
50205069
resumableState: ResumableState,

packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInlineCompleteBoundary.js

+2
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@ import {completeBoundary} from './ReactDOMFizzInstructionSetShared';
22

33
// This is a string so Closure's advanced compilation mode doesn't mangle it.
44
// eslint-disable-next-line dot-notation
5+
window['$RB'] = [];
6+
// eslint-disable-next-line dot-notation
57
window['$RC'] = completeBoundary;
Original file line numberDiff line numberDiff line change
@@ -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
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,26 @@ import {
1313
// This is a string so Closure's advanced compilation mode doesn't mangle it.
1414
// These will be renamed to local references by the external-runtime-plugin.
1515
window['$RM'] = new Map();
16+
window['$RB'] = [];
1617
window['$RX'] = clientRenderBoundary;
1718
window['$RC'] = completeBoundary;
1819
window['$RR'] = completeBoundaryWithStyles;
1920
window['$RS'] = completeSegment;
2021

2122
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
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js

+76-43
Original file line numberDiff line numberDiff line change
@@ -47,72 +47,105 @@ export function clientRenderBoundary(
4747
}
4848
}
4949

50+
const FALLBACK_THROTTLE_MS = 300;
51+
5052
export function completeBoundary(suspenseBoundaryID, contentID) {
51-
const contentNode = document.getElementById(contentID);
52-
if (!contentNode) {
53+
const contentNodeOuter = document.getElementById(contentID);
54+
if (!contentNodeOuter) {
5355
// If the client has failed hydration we may have already deleted the streaming
5456
// segments. The server may also have emitted a complete instruction but cancelled
5557
// the segment. Regardless we can ignore this case.
5658
return;
5759
}
5860
// We'll detach the content node so that regardless of what happens next we don't leave in the tree.
5961
// 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);
6163

6264
// Find the fallback's first element.
63-
const suspenseIdNode = document.getElementById(suspenseBoundaryID);
64-
if (!suspenseIdNode) {
65+
const suspenseIdNodeOuter = document.getElementById(suspenseBoundaryID);
66+
if (!suspenseIdNodeOuter) {
6567
// The user must have already navigated away from this tree.
6668
// E.g. because the parent was hydrated. That's fine there's nothing to do
6769
// but we have to make sure that we already deleted the container node.
6870
return;
6971
}
70-
// Find the boundary around the fallback. This is always the previous node.
71-
const suspenseNode = suspenseIdNode.previousSibling;
7272

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;
9790
}
98-
}
9991

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;
10494

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+
}
106127

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+
}
110133
}
111134

112-
suspenseNode.data = SUSPENSE_START_DATA;
135+
// Queue this boundary for the next batch
136+
window['$RB'].push(suspenseIdNodeOuter, contentNodeOuter);
113137

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);
116149
}
117150
}
118151

packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

+9-3
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ describe('ReactDOMFizzServer', () => {
8383
global.Node = global.window.Node;
8484
global.addEventListener = global.window.addEventListener;
8585
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);
8689
container = document.getElementById('container');
8790

8891
Scheduler = require('scheduler');
@@ -206,6 +209,7 @@ describe('ReactDOMFizzServer', () => {
206209
buffer = '';
207210

208211
if (!bufferedContent) {
212+
jest.runAllTimers();
209213
return;
210214
}
211215

@@ -314,6 +318,8 @@ describe('ReactDOMFizzServer', () => {
314318
div.innerHTML = bufferedContent;
315319
await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce);
316320
}
321+
// Let throttled boundaries reveal
322+
jest.runAllTimers();
317323
}
318324

319325
function resolveText(text) {
@@ -602,12 +608,12 @@ describe('ReactDOMFizzServer', () => {
602608
]);
603609

604610
// 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
606612
expect(
607613
Array.from(container.getElementsByTagName('script')).filter(
608614
node => node.getAttribute('nonce') === CSPnonce,
609615
).length,
610-
).toEqual(gate(flags => flags.shouldUseFizzExternalRuntime) ? 6 : 5);
616+
).toEqual(6);
611617

612618
await act(() => {
613619
resolve({default: Text});
@@ -836,7 +842,7 @@ describe('ReactDOMFizzServer', () => {
836842
container.childNodes,
837843
renderOptions.unstable_externalRuntimeSrc,
838844
).length,
839-
).toBe(1);
845+
).toBe(gate(flags => flags.shouldUseFizzExternalRuntime) ? 1 : 2);
840846
await act(() => {
841847
resolveElement({default: <Text text="Hello" />});
842848
});

0 commit comments

Comments
 (0)