Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 60 additions & 11 deletions packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Track the paint time of the shell
requestAnimationFrame(() => {
// eslint-disable-next-line dot-notation
window['$RT'] = performance.now();
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -47,72 +47,105 @@ 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.
return;
}
// 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--;
}
} else if (
data === SUSPENSE_START_DATA ||
data === SUSPENSE_PENDING_START_DATA ||
data === SUSPENSE_FALLBACK_START_DATA ||
data === ACTIVITY_START_DATA
) {
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;
}
}

const nextNode = node.nextSibling;
parentInstance.removeChild(node);
node = nextNode;
} while (node);
// Find the boundary around the fallback. This is always the previous node.
const suspenseNode = suspenseIdNode.previousSibling;

const endOfBoundary = node;
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++;
}
}

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);
}

// 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']();
}
}
}

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);
}
}

Expand Down
12 changes: 9 additions & 3 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -206,6 +209,7 @@ describe('ReactDOMFizzServer', () => {
buffer = '';

if (!bufferedContent) {
jest.runAllTimers();
return;
}

Expand Down Expand Up @@ -314,6 +318,8 @@ describe('ReactDOMFizzServer', () => {
div.innerHTML = bufferedContent;
await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce);
}
// Let throttled boundaries reveal
jest.runAllTimers();
}

function resolveText(text) {
Expand Down Expand Up @@ -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});
Expand Down Expand Up @@ -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" />});
});
Expand Down
Loading
Loading