/**
* 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.
*
* @emails react-core
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
*/
'use strict';
import {
insertNodesAndExecuteScripts,
mergeOptions,
} from '../test-utils/FizzTestUtils';
let JSDOM;
let Stream;
let React;
let ReactDOM;
let ReactDOMClient;
let ReactDOMFizzServer;
let Suspense;
let SuspenseList;
let textCache;
let loadCache;
let writable;
let CSPnonce = null;
let container;
let buffer = '';
let hasErrored = false;
let fatalError = undefined;
let renderOptions;
let waitForAll;
let assertLog;
let Scheduler;
let clientAct;
let streamingContainer;
let assertConsoleErrorDev;
describe('ReactDOMFloat', () => {
beforeEach(() => {
jest.resetModules();
JSDOM = require('jsdom').JSDOM;
const jsdom = new JSDOM(
'
',
{
runScripts: 'dangerously',
},
);
// We mock matchMedia. for simplicity it only matches 'all' or '' and misses everything else
Object.defineProperty(jsdom.window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: query === 'all' || query === '',
media: query,
})),
});
streamingContainer = null;
global.window = jsdom.window;
global.document = global.window.document;
global.navigator = global.window.navigator;
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');
CSPnonce = null;
React = require('react');
ReactDOM = require('react-dom');
ReactDOMClient = require('react-dom/client');
ReactDOMFizzServer = require('react-dom/server');
Stream = require('stream');
Suspense = React.Suspense;
SuspenseList = React.unstable_SuspenseList;
Scheduler = require('scheduler/unstable_mock');
const InternalTestUtils = require('internal-test-utils');
waitForAll = InternalTestUtils.waitForAll;
assertLog = InternalTestUtils.assertLog;
clientAct = InternalTestUtils.act;
assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
textCache = new Map();
loadCache = new Set();
buffer = '';
hasErrored = false;
writable = new Stream.PassThrough();
writable.setEncoding('utf8');
writable.on('data', chunk => {
buffer += chunk;
});
writable.on('error', error => {
hasErrored = true;
fatalError = error;
});
renderOptions = {};
if (gate(flags => flags.shouldUseFizzExternalRuntime)) {
renderOptions.unstable_externalRuntimeSrc =
'react-dom/unstable_server-external-runtime';
}
});
const bodyStartMatch = /| .*?>)/;
const headStartMatch = /| .*?>)/;
async function act(callback) {
await callback();
// Await one turn around the event loop.
// This assumes that we'll flush everything we have so far.
await new Promise(resolve => {
setImmediate(resolve);
});
if (hasErrored) {
throw fatalError;
}
// JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
// We also want to execute any scripts that are embedded.
// We assume that we have now received a proper fragment of HTML.
let bufferedContent = buffer;
buffer = '';
if (!bufferedContent) {
jest.runAllTimers();
return;
}
const bodyMatch = bufferedContent.match(bodyStartMatch);
const headMatch = bufferedContent.match(headStartMatch);
if (streamingContainer === null) {
// This is the first streamed content. We decide here where to insert it. If we get , , or
// we abandon the pre-built document and start from scratch. If we get anything else we assume it goes into the
// container. This is not really production behavior because you can't correctly stream into a deep div effectively
// but it's pragmatic for tests.
if (
bufferedContent.startsWith('') ||
bufferedContent.startsWith('') ||
bufferedContent.startsWith('') ||
bufferedContent.startsWith(' without a which is almost certainly a bug in React',
);
}
if (bufferedContent.startsWith('')) {
// we can just use the whole document
const tempDom = new JSDOM(bufferedContent);
// Wipe existing head and body content
document.head.innerHTML = '';
document.body.innerHTML = '';
// Copy the attributes over
const tempHtmlNode = tempDom.window.document.documentElement;
for (let i = 0; i < tempHtmlNode.attributes.length; i++) {
const attr = tempHtmlNode.attributes[i];
document.documentElement.setAttribute(attr.name, attr.value);
}
if (headMatch) {
// We parsed a head open tag. we need to copy head attributes and insert future
// content into
streamingContainer = document.head;
const tempHeadNode = tempDom.window.document.head;
for (let i = 0; i < tempHeadNode.attributes.length; i++) {
const attr = tempHeadNode.attributes[i];
document.head.setAttribute(attr.name, attr.value);
}
const source = document.createElement('head');
source.innerHTML = tempHeadNode.innerHTML;
await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
}
if (bodyMatch) {
// We parsed a body open tag. we need to copy head attributes and insert future
// content into
streamingContainer = document.body;
const tempBodyNode = tempDom.window.document.body;
for (let i = 0; i < tempBodyNode.attributes.length; i++) {
const attr = tempBodyNode.attributes[i];
document.body.setAttribute(attr.name, attr.value);
}
const source = document.createElement('body');
source.innerHTML = tempBodyNode.innerHTML;
await insertNodesAndExecuteScripts(source, document.body, CSPnonce);
}
if (!headMatch && !bodyMatch) {
throw new Error('expected or after ');
}
} else {
// we assume we are streaming into the default container'
streamingContainer = container;
const div = document.createElement('div');
div.innerHTML = bufferedContent;
await insertNodesAndExecuteScripts(div, container, CSPnonce);
}
} else if (streamingContainer === document.head) {
bufferedContent = '' + bufferedContent;
const tempDom = new JSDOM(bufferedContent);
const tempHeadNode = tempDom.window.document.head;
const source = document.createElement('head');
source.innerHTML = tempHeadNode.innerHTML;
await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
if (bodyMatch) {
streamingContainer = document.body;
const tempBodyNode = tempDom.window.document.body;
for (let i = 0; i < tempBodyNode.attributes.length; i++) {
const attr = tempBodyNode.attributes[i];
document.body.setAttribute(attr.name, attr.value);
}
const bodySource = document.createElement('body');
bodySource.innerHTML = tempBodyNode.innerHTML;
await insertNodesAndExecuteScripts(bodySource, document.body, CSPnonce);
}
} else {
const div = document.createElement('div');
div.innerHTML = bufferedContent;
await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce);
}
await 0;
// Let throttled boundaries reveal
jest.runAllTimers();
}
function getMeaningfulChildren(element) {
const children = [];
let node = element.firstChild;
while (node) {
if (node.nodeType === 1) {
if (
// some tags are ambiguous and might be hidden because they look like non-meaningful children
// so we have a global override where if this data attribute is included we also include the node
node.hasAttribute('data-meaningful') ||
(node.tagName === 'SCRIPT' &&
node.hasAttribute('src') &&
node.getAttribute('src') !==
renderOptions.unstable_externalRuntimeSrc &&
node.hasAttribute('async')) ||
(node.tagName !== 'SCRIPT' &&
node.tagName !== 'TEMPLATE' &&
node.tagName !== 'template' &&
!node.hasAttribute('hidden') &&
!node.hasAttribute('aria-hidden') &&
// Ignore the render blocking expect
(node.getAttribute('rel') !== 'expect' ||
node.getAttribute('blocking') !== 'render'))
) {
const props = {};
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 = getMeaningfulChildren(node);
children.push(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;
}
function BlockedOn({value, children}) {
readText(value);
return children;
}
function resolveText(text) {
const record = textCache.get(text);
if (record === undefined) {
const newRecord = {
status: 'resolved',
value: text,
};
textCache.set(text, newRecord);
} else if (record.status === 'pending') {
const thenable = record.value;
record.status = 'resolved';
record.value = text;
thenable.pings.forEach(t => t());
}
}
function readText(text) {
const record = textCache.get(text);
if (record !== undefined) {
switch (record.status) {
case 'pending':
throw record.value;
case 'rejected':
throw record.value;
case 'resolved':
return record.value;
}
} else {
const thenable = {
pings: [],
then(resolve) {
if (newRecord.status === 'pending') {
thenable.pings.push(resolve);
} else {
Promise.resolve().then(() => resolve(newRecord.value));
}
},
};
const newRecord = {
status: 'pending',
value: thenable,
};
textCache.set(text, newRecord);
throw thenable;
}
}
function AsyncText({text}) {
return readText(text);
}
function renderToPipeableStream(jsx, options) {
// Merge options with renderOptions, which may contain featureFlag specific behavior
return ReactDOMFizzServer.renderToPipeableStream(
jsx,
mergeOptions(options, renderOptions),
);
}
function loadPreloads(hrefs) {
const event = new window.Event('load');
const nodes = document.querySelectorAll('link[rel="preload"]');
resolveLoadables(hrefs, nodes, event, href =>
Scheduler.log('load preload: ' + href),
);
}
function errorPreloads(hrefs) {
const event = new window.Event('error');
const nodes = document.querySelectorAll('link[rel="preload"]');
resolveLoadables(hrefs, nodes, event, href =>
Scheduler.log('error preload: ' + href),
);
}
function loadStylesheets(hrefs) {
loadStylesheetsFrom(document, hrefs);
}
function loadStylesheetsFrom(root, hrefs) {
const event = new window.Event('load');
const nodes = root.querySelectorAll('link[rel="stylesheet"]');
resolveLoadables(hrefs, nodes, event, href => {
Scheduler.log('load stylesheet: ' + href);
});
}
function errorStylesheets(hrefs) {
const event = new window.Event('error');
const nodes = document.querySelectorAll('link[rel="stylesheet"]');
resolveLoadables(hrefs, nodes, event, href => {
Scheduler.log('error stylesheet: ' + href);
});
}
function resolveLoadables(hrefs, nodes, event, onLoad) {
const hrefSet = hrefs ? new Set(hrefs) : null;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (loadCache.has(node)) {
continue;
}
const href = node.getAttribute('href');
if (!hrefSet || hrefSet.has(href)) {
loadCache.add(node);
onLoad(href);
node.dispatchEvent(event);
}
}
}
it('can render resources before singletons', async () => {
const root = ReactDOMClient.createRoot(document);
root.render(
<>
foo
hello world
>,
);
try {
await waitForAll([]);
} catch (e) {
// for DOMExceptions that happen when expecting this test to fail we need
// to clear the scheduler first otherwise the expected failure will fail
await waitForAll([]);
throw e;
}
expect(getMeaningfulChildren(document)).toEqual(
foo
hello world
,
);
});
it('can hydrate non Resources in head when Resources are also inserted there', async () => {
await act(() => {
const {pipe} = renderToPipeableStream(
{}} />
foo
' +
(gate(flags => flags.shouldUseFizzExternalRuntime)
? ''
: '') +
(gate(flags => flags.enableFizzBlockingRender)
? ''
: '') +
'foo' +
'bar' +
(gate(flags => flags.enableFizzBlockingRender)
? ''
: ''),
'',
]);
});
it('dedupes if the external runtime is explicitly loaded using preinit', async () => {
const unstable_externalRuntimeSrc = 'src-of-external-runtime';
function App() {
ReactDOM.preinit(unstable_externalRuntimeSrc, {as: 'script'});
return (
,
);
});
it('normalizes stylesheet resource precedence for all boundaries inlined as part of the shell flush', async () => {
await act(() => {
const {pipe} = renderToPipeableStream(
,
);
});
it('stylesheet resources are inserted according to precedence order on the client', async () => {
await act(() => {
const {pipe} = renderToPipeableStream(
,
);
});
it('will hoist resources of child boundaries emitted as part of a partial boundary to the parent boundary', async () => {
await act(() => {
const {pipe} = renderToPipeableStream(
,
);
// This will enqueue a stylesheet resource in a deep blocked boundary (loading baz...).
await act(() => {
resolveText('baz');
});
expect(getMeaningfulChildren(document)).toEqual(
loading...
,
);
// This will enqueue a stylesheet resource in the intermediate blocked boundary (loading bar...).
await act(() => {
resolveText('bar');
});
expect(getMeaningfulChildren(document)).toEqual(
loading...
,
);
// This will complete a segment in the top level boundary that is still blocked on another segment.
// It will flush the completed segment however the inner boundaries should not emit their style dependencies
// because they are not going to be revealed yet. instead their dependencies are hoisted to the blocked
// boundary (top level).
await act(() => {
resolveText('foo');
});
expect(getMeaningfulChildren(document)).toEqual(
loading...
,
);
// This resolves the last blocked segment on the top level boundary so we see all dependencies of the
// nested boundaries emitted at this level
await act(() => {
resolveText('qux');
});
expect(getMeaningfulChildren(document)).toEqual(
loading...
,
);
// We load all stylesheets and confirm the content is revealed
await act(() => {
const event = document.createEvent('Events');
event.initEvent('load', true, true);
Array.from(document.querySelectorAll('link[rel="stylesheet"]')).forEach(
el => {
el.dispatchEvent(event);
},
);
});
expect(getMeaningfulChildren(document)).toEqual(
foo
bar
baz
qux
,
);
});
it('encodes attributes consistently whether resources are flushed in shell or in late boundaries', async () => {
function App() {
return (
,
);
assertConsoleErrorDev([
'React does not recognize the `nonStandardAttr` prop on a DOM element. ' +
'If you intentionally want it to appear in the DOM as a custom attribute, ' +
'spell it as lowercase `nonstandardattr` instead. If you accidentally passed it from a ' +
'parent component, remove it from the DOM element.\n' +
' in link (at **)\n' +
' in App (at **)',
'Invalid values for props `shouldnotincludefunctions`, `norsymbols` on tag. ' +
'Either remove them from the element, or pass a string or number value to keep them in the DOM. ' +
'For details, see https://react.dev/link/attribute-behavior \n' +
' in link (at **)\n' +
' in App (at **)',
]);
// Now we flush the stylesheet with the boundary
await act(() => {
resolveText('unblock');
});
expect(getMeaningfulChildren(document)).toEqual(
loading...
,
);
});
it('boundary stylesheet resource dependencies hoist to a parent boundary when flushed inline', async () => {
await act(() => {
const {pipe} = renderToPipeableStream(
);
}
await act(() => {
renderToPipeableStream().pipe(writable);
});
expect(getMeaningfulChildren(document)).toEqual(
title
title
,
);
ReactDOMClient.hydrateRoot(document, );
await waitForAll([]);
expect(getMeaningfulChildren(document)).toEqual(
title
title
,
);
});
it('warns if you render tag with itemProp outside or ', async () => {
const root = ReactDOMClient.createRoot(document);
root.render(
,
);
await waitForAll([]);
assertConsoleErrorDev([
'Cannot render a outside the main document if it has an `itemProp` prop. ' +
'`itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. ' +
'If you were intending for React to hoist this remove the `itemProp` prop. ' +
'Otherwise, try moving this tag into the or of the Document.\n' +
' in html (at **)',
'In HTML, cannot be a child of .\n' +
'This will cause a hydration error.\n' +
'\n' +
'> \n' +
'> ' +
'\n' +
'\n in meta (at **)',
]);
});
it('warns if you render a tag with itemProp outside or ', async () => {
const root = ReactDOMClient.createRoot(document);
root.render(
title
,
);
await waitForAll([]);
assertConsoleErrorDev([
'Cannot render a outside the main document if it has an `itemProp` prop. ' +
'`itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. ' +
'If you were intending for React to hoist this remove the `itemProp` prop. ' +
'Otherwise, try moving this tag into the or of the Document.\n' +
' in html (at **)',
'In HTML, cannot be a child of .\n' +
'This will cause a hydration error.\n' +
'\n' +
'> \n' +
'> ' +
'\n' +
'\n in title (at **)',
]);
});
it('warns if you render a
,
);
await waitForAll([]);
assertConsoleErrorDev([
'Cannot render a