/**
* 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';
let JSDOM;
let Stream;
let React;
let ReactDOM;
let ReactDOMClient;
let ReactDOMFizzServer;
let document;
let writable;
let container;
let buffer = '';
let hasErrored = false;
let fatalError = undefined;
let waitForAll;
describe('ReactDOM HostSingleton', () => {
beforeEach(() => {
jest.resetModules();
JSDOM = require('jsdom').JSDOM;
React = require('react');
ReactDOM = require('react-dom');
ReactDOMClient = require('react-dom/client');
ReactDOMFizzServer = require('react-dom/server');
Stream = require('stream');
const InternalTestUtils = require('internal-test-utils');
waitForAll = InternalTestUtils.waitForAll;
// Test Environment
const jsdom = new JSDOM(
'<!DOCTYPE html><html><head></head><body><div id="container">',
{
runScripts: 'dangerously',
},
);
document = jsdom.window.document;
container = document.getElementById('container');
buffer = '';
hasErrored = false;
writable = new Stream.PassThrough();
writable.setEncoding('utf8');
writable.on('data', chunk => {
buffer += chunk;
});
writable.on('error', error => {
hasErrored = true;
fatalError = error;
});
});
async function actIntoEmptyDocument(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;
}
const bufferedContent = buffer;
buffer = '';
const jsdom = new JSDOM(bufferedContent, {
runScripts: 'dangerously',
});
document = jsdom.window.document;
container = document;
}
function getVisibleChildren(element) {
const children = [];
let node = element.firstChild;
while (node) {
if (node.nodeType === 1) {
const el: Element = (node: any);
if (
(el.tagName !== 'SCRIPT' &&
el.tagName !== 'TEMPLATE' &&
el.tagName !== 'template' &&
!el.hasAttribute('hidden') &&
!el.hasAttribute('aria-hidden')) ||
el.hasAttribute('data-meaningful')
) {
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 = getVisibleChildren(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;
}
// @gate enableHostSingletons && enableFloat
it('warns if you render the same singleton twice at the same time', async () => {
const root = ReactDOMClient.createRoot(document);
root.render(
<html>
<head lang="en">
<title>Hello</title>
</head>
<body />
</html>,
);
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head lang="en">
<title>Hello</title>
</head>
<body />
</html>,
);
root.render(
<html>
<head lang="en">
<title>Hello</title>
</head>
<head lang="es" data-foo="foo">
<title>Hola</title>
</head>
<body />
</html>,
);
await expect(async () => {
await waitForAll([]);
}).toErrorDev(
'Warning: You are mounting a new head component when a previous one has not first unmounted. It is an error to render more than one head component at a time and attributes and children of these components will likely fail in unpredictable ways. Please only render a single instance of <head> and if you need to mount a new one, ensure any previous ones have unmounted first',
);
expect(getVisibleChildren(document)).toEqual(
<html>
<head lang="es" data-foo="foo">
<title>Hola</title>
<title>Hello</title>
</head>
<body />
</html>,
);
root.render(
<html>
{null}
{null}
<head lang="fr">
<title>Bonjour</title>
</head>
<body />
</html>,
);
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head lang="fr">
<title>Bonjour</title>
</head>
<body />
</html>,
);
root.render(
<html>
<head lang="en">
<title>Hello</title>
</head>
<body />
</html>,
);
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head lang="en">
<title>Hello</title>
</head>
<body />
</html>,
);
});
// @gate enableHostSingletons && enableFloat
it('renders into html, head, and body persistently so the node identities never change and extraneous styles are retained', async () => {
gate(flags => {
if (flags.enableHostSingletons !== true) {
// We throw here because when this test fails it ends up with sync work in a microtask
// that throws after the expectTestToFail check asserts the failure. this causes even the
// expected failure to fail. This just fails explicitly and early
throw new Error('manually opting out of test');
}
});
// Server render some html that will get replaced with a client render
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<html data-foo="foo">
<head data-bar="bar">
<link rel="stylesheet" href="resource" />
<title>a server title</title>
<link rel="stylesheet" href="3rdparty" />
<link rel="stylesheet" href="3rdparty2" />
</head>
<body data-baz="baz">
<div>hello world</div>
<style>
{`
body: {
background-color: red;
}`}
</style>
<div>goodbye</div>
</body>
</html>,
);
pipe(writable);
});
expect(getVisibleChildren(document)).toEqual(
<html data-foo="foo">
<head data-bar="bar">
<title>a server title</title>
<link rel="stylesheet" href="resource" />
<link rel="stylesheet" href="3rdparty" />
<link rel="stylesheet" href="3rdparty2" />
</head>
<body data-baz="baz">
<div>hello world</div>
<style>
{`
body: {
background-color: red;
}`}
</style>
<div>goodbye</div>
</body>
</html>,
);
const {documentElement, head, body} = document;
const persistentElements = [documentElement, head, body];
// Render into the document completely different html. Observe that styles
// are retained as are html, body, and head referential identities. Because this was
// server rendered and we are not hydrating we lose the semantic placement of the original
// head contents and everything gets preprended. In a future update we might emit an insertion
// edge from the server and make client rendering reslilient to interstitial placement
const root = ReactDOMClient.createRoot(document);
root.render(
<html data-client-foo="foo">
<head>
<title>a client title</title>
</head>
<body data-client-baz="baz">
<div>hello client</div>
</body>
</html>,
);
await waitForAll([]);
expect(persistentElements).toEqual([
document.documentElement,
document.head,
document.body,
]);
// Similar to Hydration we don't reset attributes on the instance itself even on a fresh render.
expect(getVisibleChildren(document)).toEqual(
<html data-client-foo="foo">
<head>
<link rel="stylesheet" href="resource" />
<link rel="stylesheet" href="3rdparty" />
<link rel="stylesheet" href="3rdparty2" />
<title>a client title</title>
</head>
<body data-client-baz="baz">
<style>
{`
body: {
background-color: red;
}`}
</style>
<div>hello client</div>
</body>
</html>,
);
// Render new children and assert they append in the correct locations
root.render(
<html data-client-foo="foo">
<head>
<title>a client title</title>
<meta />
</head>
<body data-client-baz="baz">
<p>hello client again</p>
<div>hello client</div>
</body>
</html>,
);
await waitForAll([]);
expect(persistentElements).toEqual([
document.documentElement,
document.head,
document.body,
]);
expect(getVisibleChildren(document)).toEqual(
<html data-client-foo="foo">
<head>
<link rel="stylesheet" href="resource" />
<link rel="stylesheet" href="3rdparty" />
<link rel="stylesheet" href="3rdparty2" />
<title>a client title</title>
<meta />
</head>
<body data-client-baz="baz">
<style>
{`
body: {
background-color: red;
}`}
</style>
<p>hello client again</p>
<div>hello client</div>
</body>
</html>,
);
// Remove some children
root.render(
<html data-client-foo="foo">
<head>
<title>a client title</title>
</head>
<body data-client-baz="baz">
<p>hello client again</p>
</body>
</html>,
);
await waitForAll([]);
expect(persistentElements).toEqual([
document.documentElement,
document.head,
document.body,
]);
expect(getVisibleChildren(document)).toEqual(
<html data-client-foo="foo">
<head>
<link rel="stylesheet" href="resource" />
<link rel="stylesheet" href="3rdparty" />
<link rel="stylesheet" href="3rdparty2" />
<title>a client title</title>
</head>
<body data-client-baz="baz">
<style>
{`
body: {
background-color: red;
}`}
</style>
<p>hello client again</p>
</body>
</html>,
);
// Remove a persistent component
// @TODO figure out whether to clean up attributes. restoring them is likely
// not possible.
root.render(
<html data-client-foo="foo">
<head>
<title>a client title</title>
</head>
</html>,
);
await waitForAll([]);
expect(persistentElements).toEqual([
document.documentElement,
document.head,
document.body,
]);
expect(getVisibleChildren(document)).toEqual(
<html data-client-foo="foo">
<head>
<link rel="stylesheet" href="resource" />
<link rel="stylesheet" href="3rdparty" />
<link rel="stylesheet" href="3rdparty2" />
<title>a client title</title>
</head>
<body>
<style>
{`
body: {
background-color: red;
}`}
</style>
</body>
</html>,
);
// unmount the root
root.unmount();
await waitForAll([]);
expect(persistentElements).toEqual([
document.documentElement,
document.head,
document.body,
]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="resource" />
<link rel="stylesheet" href="3rdparty" />
<link rel="stylesheet" href="3rdparty2" />
</head>
<body>
<style>
{`
body: {
background-color: red;
}`}
</style>
</body>
</html>,
);
// Now let's hydrate the document with known mismatching content
// We assert that the identities of html, head, and body still haven't changed
// and that the embedded styles are still retained
const hydrationErrors = [];
let hydrateRoot = ReactDOMClient.hydrateRoot(
document,
<html data-client-foo="foo">
<head>
<title>a client title</title>
</head>
<body data-client-baz="baz">
<div>hello client</div>
</body>
</html>,
{
onRecoverableError(error, errorInfo) {
hydrationErrors.push([
error.message,
errorInfo.componentStack
? errorInfo.componentStack.split('\n')[1].trim()
: null,
]);
},
},
);
await expect(async () => {
await waitForAll([]);
}).toErrorDev(
[
`Warning: Expected server HTML to contain a matching <div> in <body>.
in div (at **)
in body (at **)
in html (at **)`,
`Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.`,
],
{withoutStack: 1},
);
expect(hydrationErrors).toEqual([
[
'Hydration failed because the initial UI does not match what was rendered on the server.',
'at div',
],
[
'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
null,
],
]);
expect(persistentElements).toEqual([
document.documentElement,
document.head,
document.body,
]);
expect(getVisibleChildren(document)).toEqual(
<html data-client-foo="foo">
<head>
<link rel="stylesheet" href="resource" />
<link rel="stylesheet" href="3rdparty" />
<link rel="stylesheet" href="3rdparty2" />
<title>a client title</title>
</head>
<body data-client-baz="baz">
<style>
{`
body: {
background-color: red;
}`}
</style>
<div>hello client</div>
</body>
</html>,
);
// Reset the tree
hydrationErrors.length = 0;
hydrateRoot.unmount();
// Now we try hydrating again with matching nodes and we ensure
// the retained styles are bound to the hydrated fibers
const link = document.querySelector('link[rel="stylesheet"]');
const style = document.querySelector('style');
hydrateRoot = ReactDOMClient.hydrateRoot(
document,
<html data-client-foo="foo">
<head>
<link rel="stylesheet" href="resource" />
<link rel="stylesheet" href="3rdparty" />
<link rel="stylesheet" href="3rdparty2" />
</head>
<body data-client-baz="baz">
<style>
{`
body: {
background-color: red;
}`}
</style>
</body>
</html>,
{
onRecoverableError(error, errorInfo) {
hydrationErrors.push([
error.message,
errorInfo.componentStack
? errorInfo.componentStack.split('\n')[1].trim()
: null,
]);
},
},
);
expect(hydrationErrors).toEqual([]);
await waitForAll([]);
expect(persistentElements).toEqual([
document.documentElement,
document.head,
document.body,
]);
expect([link, style]).toEqual([
document.querySelector('link[rel="stylesheet"]'),
document.querySelector('style'),
]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="resource" />
<link rel="stylesheet" href="3rdparty" />
<link rel="stylesheet" href="3rdparty2" />
</head>
<body>
<style>
{`
body: {
background-color: red;
}`}
</style>
</body>
</html>,
);
// We unmount a final time and observe that still we retain our persistent nodes
// but they style contents which matched in hydration is removed
hydrateRoot.unmount();
expect(persistentElements).toEqual([
document.documentElement,
document.head,
document.body,
]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body />
</html>,
);
});
// This test is not supported in this implementation. If we reintroduce insertion edge we should revisit
// @gate enableHostSingletons
xit('is able to maintain insertions in head and body between tree-adjacent Nodes', async () => {
// Server render some html and hydrate on the client
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<html>
<head>
<title>title</title>
</head>
<body>
<div>hello</div>
</body>
</html>,
);
pipe(writable);
});
const root = ReactDOMClient.hydrateRoot(
document,
<html>
<head>
<title>title</title>
</head>
<body>
<div>hello</div>
</body>
</html>,
);
await waitForAll([]);
// We construct and insert some artificial stylesheets mimicing what a 3rd party script might do
// In the future we could hydrate with these already in the document but the rules are restrictive
// still so it would fail and fall back to client rendering
const [a, b, c, d, e, f, g, h] = 'abcdefgh'.split('').map(letter => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = letter;
return link;
});
const head = document.head;
const title = head.firstChild;
head.insertBefore(a, title);
head.insertBefore(b, title);
head.appendChild(c);
head.appendChild(d);
const bodyContent = document.body.firstChild;
const body = document.body;
body.insertBefore(e, bodyContent);
body.insertBefore(f, bodyContent);
body.appendChild(g);
body.appendChild(h);
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="a" />
<link rel="stylesheet" href="b" />
<title>title</title>
<link rel="stylesheet" href="c" />
<link rel="stylesheet" href="d" />
</head>
<body>
<link rel="stylesheet" href="e" />
<link rel="stylesheet" href="f" />
<div>hello</div>
<link rel="stylesheet" href="g" />
<link rel="stylesheet" href="h" />
</body>
</html>,
);
// Unmount head and change children of body
root.render(
<html>
{null}
<body>
<div>hello</div>
<div>world</div>
</body>
</html>,
);
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="a" />
<link rel="stylesheet" href="b" />
<link rel="stylesheet" href="c" />
<link rel="stylesheet" href="d" />
</head>
<body>
<link rel="stylesheet" href="e" />
<link rel="stylesheet" href="f" />
<div>hello</div>
<div>world</div>
<link rel="stylesheet" href="g" />
<link rel="stylesheet" href="h" />
</body>
</html>,
);
// Mount new head and unmount body
root.render(
<html>
<head>
<title>a new title</title>
</head>
</html>,
);
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<title>a new title</title>
<link rel="stylesheet" href="a" />
<link rel="stylesheet" href="b" />
<link rel="stylesheet" href="c" />
<link rel="stylesheet" href="d" />
</head>
<body>
<link rel="stylesheet" href="e" />
<link rel="stylesheet" href="f" />
<link rel="stylesheet" href="g" />
<link rel="stylesheet" href="h" />
</body>
</html>,
);
});
// @gate enableHostSingletons
it('clears persistent head and body when html is the container', async () => {
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<html>
<head>
<link rel="stylesheet" href="headbefore" />
<title>this should be removed</title>
<link rel="stylesheet" href="headafter" />
<script data-meaningful="">true</script>
</head>
<body>
<link rel="stylesheet" href="bodybefore" />
<div>this should be removed</div>
<link rel="stylesheet" href="bodyafter" />
<script data-meaningful="">true</script>
</body>
</html>,
);
pipe(writable);
});
container = document.documentElement;
const root = ReactDOMClient.createRoot(container);
root.render(
<>
<head>
<title>something new</title>
</head>
<body>
<div>something new</div>
</body>
</>,
);
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="headbefore" />
<link rel="stylesheet" href="headafter" />
<script data-meaningful="">true</script>
<title>something new</title>
</head>
<body>
<link rel="stylesheet" href="bodybefore" />
<link rel="stylesheet" href="bodyafter" />
<script data-meaningful="">true</script>
<div>something new</div>
</body>
</html>,
);
});
// @gate enableHostSingletons
it('clears persistent head when it is the container', async () => {
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<html>
<head>
<link rel="stylesheet" href="before" />
<title>this should be removed</title>
<link rel="stylesheet" href="after" />
</head>
<body />
</html>,
);
pipe(writable);
});
container = document.head;
const root = ReactDOMClient.createRoot(container);
root.render(<title>something new</title>);
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="before" />
<link rel="stylesheet" href="after" />
<title>something new</title>
</head>
<body />
</html>,
);
});
// @gate enableHostSingletons && enableFloat
it('clears persistent body when it is the container', async () => {
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<html>
<head />
<body>
<link rel="stylesheet" href="before" />
<div>this should be removed</div>
<link rel="stylesheet" href="after" />
</body>
</html>,
);
pipe(writable);
});
container = document.body;
const root = ReactDOMClient.createRoot(container);
root.render(<div>something new</div>);
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body>
<link rel="stylesheet" href="before" />
<link rel="stylesheet" href="after" />
<div>something new</div>
</body>
</html>,
);
});
it('renders single Text children into HostSingletons correctly', async () => {
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<html>
<head />
<body>foo</body>
</html>,
);
pipe(writable);
});
let root = ReactDOMClient.hydrateRoot(
document,
<html>
<head />
<body>foo</body>
</html>,
);
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body>foo</body>
</html>,
);
root.render(
<html>
<head />
<body>bar</body>
</html>,
);
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body>bar</body>
</html>,
);
root.unmount();
root = ReactDOMClient.createRoot(document);
root.render(
<html>
<head />
<body>baz</body>
</html>,
);
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body>baz</body>
</html>,
);
});
it('supports going from single text child to many children back to single text child in body', async () => {
const root = ReactDOMClient.createRoot(document);
root.render(
<html>
<head />
<body>foo</body>
</html>,
);
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body>foo</body>
</html>,
);
root.render(
<html>
<head />
<body>
<div>foo</div>
</body>
</html>,
);
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body>
<div>foo</div>
</body>
</html>,
);
root.render(
<html>
<head />
<body>foo</body>
</html>,
);
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body>foo</body>
</html>,
);
root.render(
<html>
<head />
<body>
<div>foo</div>
</body>
</html>,
);
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body>
<div>foo</div>
</body>
</html>,
);
});
// @gate enableHostSingletons
it('allows for hydrating without a head', async () => {
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<html>
<body>foo</body>
</html>,
);
pipe(writable);
});
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body>foo</body>
</html>,
);
ReactDOMClient.hydrateRoot(
document,
<html>
<body>foo</body>
</html>,
);
await waitForAll([]);
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body>foo</body>
</html>,
);
});
// https://github.com/facebook/react/issues/26128
it('(#26128) does not throw when rendering at body', async () => {
ReactDOM.render(<div />, document.body);
});
// https://github.com/facebook/react/issues/26128
it('(#26128) does not throw when rendering at <html>', async () => {
ReactDOM.render(<body />, document.documentElement);
});
// https://github.com/facebook/react/issues/26128
it('(#26128) does not throw when rendering at document', async () => {
ReactDOM.render(<html />, document);
});
});