mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
When streaming SSR while hydrating React will wait for Suspense boundaries to be revealed by the SSR stream before attempting to hydrate them. The rationale here is that the Server render is likely further ahead of whatever the client would produce so waiting to let the server stream in the UI is preferable to retrying on the client and possibly delaying how quickly the primary content becomes available. However If the connection closes early (user hits stop for instance) or there is a server error which prevents additional HTML from being delivered to the client this can put React into a broken state where the boundary never resolves nor errors and the hydration never retries that boundary freezing it in it's fallback state. Once the document has fully loaded we know there is not way any additional Suspense boundaries can arrive. This update changes react-dom on the client to schedule client renders for any unfinished Suspense boundaries upon document loading. The technique for client rendering a fallback is pretty straight forward. When hydrating a Suspense boundary if the Document is in 'complete' readyState we interpret pending boundaries as fallback boundaries. If the readyState is not 'complete' we register an event to retry the boundary when the DOMContentLoaded event fires. To test this I needed JSDOM to model readyState. We previously had a temporary implementation of readyState for SSR streaming but I ended up implementing this as a mock of JSDOM that implements a fake readyState that is mutable. It starts off in 'loading' readyState and you can advance it by mutating document.readyState. You can also reset it to 'loading'. It fires events when changing states. This seems like the least invasive way to get closer-to-real-browser behavior in a way that won't require remembering this subtle detail every time you create a test that asserts Suspense resolution order.
190 lines
5.5 KiB
JavaScript
190 lines
5.5 KiB
JavaScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @flow
|
|
*/
|
|
'use strict';
|
|
|
|
async function insertNodesAndExecuteScripts(
|
|
source: Document | Element,
|
|
target: Node,
|
|
CSPnonce: string | null,
|
|
) {
|
|
const ownerDocument = target.ownerDocument || target;
|
|
|
|
// We need to remove the script content for any scripts that would not run based on CSP
|
|
// We restore the script content after moving the nodes into the target
|
|
const badNonceScriptNodes: Map<Element, string> = new Map();
|
|
if (CSPnonce) {
|
|
const scripts = source.querySelectorAll('script');
|
|
for (let i = 0; i < scripts.length; i++) {
|
|
const script = scripts[i];
|
|
if (
|
|
!script.hasAttribute('src') &&
|
|
script.getAttribute('nonce') !== CSPnonce
|
|
) {
|
|
badNonceScriptNodes.set(script, script.textContent);
|
|
script.textContent = '';
|
|
}
|
|
}
|
|
}
|
|
let lastChild = null;
|
|
while (source.firstChild) {
|
|
const node = source.firstChild;
|
|
if (lastChild === node) {
|
|
throw new Error('Infinite loop.');
|
|
}
|
|
lastChild = node;
|
|
|
|
if (node.nodeType === 1) {
|
|
const element: Element = (node: any);
|
|
if (
|
|
// $FlowFixMe[prop-missing]
|
|
element.dataset != null &&
|
|
(element.dataset.rxi != null ||
|
|
element.dataset.rri != null ||
|
|
element.dataset.rci != null ||
|
|
element.dataset.rsi != null)
|
|
) {
|
|
// Fizz external runtime instructions are expected to be in the body.
|
|
// When we have renderIntoContainer and renderDocument this will be
|
|
// more enforceable. At the moment you can misconfigure your stream and end up
|
|
// with instructions that are deep in the document
|
|
(ownerDocument.body: any).appendChild(element);
|
|
} else {
|
|
target.appendChild(element);
|
|
|
|
if (element.nodeName === 'SCRIPT') {
|
|
await executeScript(element);
|
|
} else {
|
|
const scripts = element.querySelectorAll('script');
|
|
for (let i = 0; i < scripts.length; i++) {
|
|
const script = scripts[i];
|
|
await executeScript(script);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
target.appendChild(node);
|
|
}
|
|
}
|
|
|
|
// restore the textContent now that we have finished attempting to execute scripts
|
|
badNonceScriptNodes.forEach((scriptContent, script) => {
|
|
script.textContent = scriptContent;
|
|
});
|
|
}
|
|
|
|
async function executeScript(script: Element) {
|
|
const ownerDocument = script.ownerDocument;
|
|
if (script.parentNode == null) {
|
|
throw new Error(
|
|
'executeScript expects to be called on script nodes that are currently in a document',
|
|
);
|
|
}
|
|
const parent = script.parentNode;
|
|
const scriptSrc = script.getAttribute('src');
|
|
if (scriptSrc) {
|
|
if (document !== ownerDocument) {
|
|
throw new Error(
|
|
'You must set the current document to the global document to use script src in tests',
|
|
);
|
|
}
|
|
|
|
try {
|
|
// $FlowFixMe
|
|
require(scriptSrc);
|
|
} catch (x) {
|
|
const event = new window.ErrorEvent('error', {error: x});
|
|
window.dispatchEvent(event);
|
|
}
|
|
} else {
|
|
const newScript = ownerDocument.createElement('script');
|
|
newScript.textContent = script.textContent;
|
|
// make sure to add nonce back to script if it exists
|
|
for (let i = 0; i < script.attributes.length; i++) {
|
|
const attribute = script.attributes[i];
|
|
newScript.setAttribute(attribute.name, attribute.value);
|
|
}
|
|
|
|
parent.insertBefore(newScript, script);
|
|
parent.removeChild(script);
|
|
}
|
|
}
|
|
|
|
function mergeOptions(options: Object, defaultOptions: Object): Object {
|
|
return {
|
|
...defaultOptions,
|
|
...options,
|
|
};
|
|
}
|
|
|
|
function stripExternalRuntimeInNodes(
|
|
nodes: HTMLElement[] | HTMLCollection<HTMLElement>,
|
|
externalRuntimeSrc: string | null,
|
|
): HTMLElement[] {
|
|
if (!Array.isArray(nodes)) {
|
|
nodes = Array.from(nodes);
|
|
}
|
|
if (externalRuntimeSrc == null) {
|
|
return nodes;
|
|
}
|
|
return nodes.filter(
|
|
n =>
|
|
(n.tagName !== 'SCRIPT' && n.tagName !== 'script') ||
|
|
n.getAttribute('src') !== externalRuntimeSrc,
|
|
);
|
|
}
|
|
|
|
function getVisibleChildren(element: Element): React$Node {
|
|
const children = [];
|
|
let node: any = element.firstChild;
|
|
while (node) {
|
|
if (node.nodeType === 1) {
|
|
if (
|
|
((node.tagName !== 'SCRIPT' && node.tagName !== 'script') ||
|
|
node.hasAttribute('data-meaningful')) &&
|
|
node.tagName !== 'TEMPLATE' &&
|
|
node.tagName !== 'template' &&
|
|
!node.hasAttribute('hidden') &&
|
|
!node.hasAttribute('aria-hidden')
|
|
) {
|
|
const props: any = {};
|
|
const attributes = node.attributes;
|
|
for (let i = 0; i < attributes.length; i++) {
|
|
if (
|
|
attributes[i].name === 'id' &&
|
|
attributes[i].value.includes(':')
|
|
) {
|
|
// We assume this is a React added ID that's a non-visual implementation detail.
|
|
continue;
|
|
}
|
|
props[attributes[i].name] = attributes[i].value;
|
|
}
|
|
props.children = getVisibleChildren(node);
|
|
children.push(
|
|
require('react').createElement(node.tagName.toLowerCase(), props),
|
|
);
|
|
}
|
|
} else if (node.nodeType === 3) {
|
|
children.push(node.data);
|
|
}
|
|
node = node.nextSibling;
|
|
}
|
|
return children.length === 0
|
|
? undefined
|
|
: children.length === 1
|
|
? children[0]
|
|
: children;
|
|
}
|
|
|
|
export {
|
|
insertNodesAndExecuteScripts,
|
|
mergeOptions,
|
|
stripExternalRuntimeInNodes,
|
|
getVisibleChildren,
|
|
};
|