mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
[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:
+61
-12
@@ -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,
|
||||
|
||||
Vendored
+2
@@ -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;
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
// Track the paint time of the shell
|
||||
requestAnimationFrame(() => {
|
||||
// eslint-disable-next-line dot-notation
|
||||
window['$RT'] = performance.now();
|
||||
});
|
||||
+17
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
+4
-2
@@ -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 =
|
||||
|
||||
Vendored
+77
-44
@@ -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
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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', ''))};`;
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user