Files
react/packages/react-dom/src/test-utils/FizzTestUtils.js
T
Josh Story 49eba01930 [Fizz][Float] Refactor Resources (#27400)
Refactors Resources to have a more compact and memory efficient
struture. Resources generally are just an Array of chunks. A resource is
flushed when it's chunks is length zero. A resource does not have any
other state.

Stylesheets and Style tags are different and have been modeled as a unit
as a StyleQueue. This object stores the style rules to flush as part of
style tags using precedence as well as all the stylesheets associated
with the precedence. Stylesheets still need to track state because it
affects how we issue boundary completion instructions. Additionally
stylesheets encode chunks lazily because we may never write them as html
if they are discovered late.

The preload props transfer is now maximally compact (only stores the
props we would ever actually adopt) and only stores props for
stylesheets and scripts because other preloads have no resource
counterpart to adopt props into. The ResumableState maps that track
which keys have been observed are being overloaded. Previously if a key
was found it meant that a resource already exists (either in this render
or in a prior prerender). Now we discriminate between null and object
values. If map value is null we can assume the resource exists but if it
is an object that represents a prior preload for that resource and the
resource must still be constructed.
2023-09-26 09:59:39 -07:00

224 lines
6.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,
);
}
// Since JSDOM doesn't implement a streaming HTML parser, we manually overwrite
// readyState here (currently read by ReactDOMServerExternalRuntime). This does
// not trigger event callbacks, but we do not rely on any right now.
async function withLoadingReadyState<T>(
fn: () => T,
document: Document,
): Promise<T> {
// JSDOM implements readyState in document's direct prototype, but this may
// change in later versions
let prevDescriptor = null;
let proto: Object = document;
while (proto != null) {
prevDescriptor = Object.getOwnPropertyDescriptor(proto, 'readyState');
if (prevDescriptor != null) {
break;
}
proto = Object.getPrototypeOf(proto);
}
Object.defineProperty(document, 'readyState', {
get() {
return 'loading';
},
configurable: true,
});
const result = await fn();
// $FlowFixMe[incompatible-type]
delete document.readyState;
if (prevDescriptor) {
Object.defineProperty(proto, 'readyState', prevDescriptor);
}
return result;
}
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,
withLoadingReadyState,
getVisibleChildren,
};