/**
* 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,
};