mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
e0fe347967
Bassed off: https://github.com/facebook/react/pull/32425 Wait to land internally. [Commit to review.](https://github.com/facebook/react/pull/32426/commits/66aa6a4dbb78106b4f3d3eb367f5c27eb8f30c66) This has landed everywhere
964 lines
32 KiB
JavaScript
964 lines
32 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.
|
|
*
|
|
* @emails react-core
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
let React;
|
|
let ReactDOM;
|
|
let findDOMNode;
|
|
let ReactDOMClient;
|
|
let ReactDOMServer;
|
|
let assertConsoleErrorDev;
|
|
let act;
|
|
|
|
describe('ReactDOM', () => {
|
|
beforeEach(() => {
|
|
jest.resetModules();
|
|
React = require('react');
|
|
ReactDOM = require('react-dom');
|
|
ReactDOMClient = require('react-dom/client');
|
|
ReactDOMServer = require('react-dom/server');
|
|
findDOMNode =
|
|
ReactDOM.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE
|
|
.findDOMNode;
|
|
|
|
({act, assertConsoleErrorDev} = require('internal-test-utils'));
|
|
});
|
|
|
|
it('should bubble onSubmit', async () => {
|
|
const container = document.createElement('div');
|
|
|
|
let count = 0;
|
|
let buttonRef;
|
|
|
|
function Parent() {
|
|
return (
|
|
<div
|
|
onSubmit={event => {
|
|
event.preventDefault();
|
|
count++;
|
|
}}>
|
|
<Child />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Child() {
|
|
return (
|
|
<form>
|
|
<input type="submit" ref={button => (buttonRef = button)} />
|
|
</form>
|
|
);
|
|
}
|
|
|
|
document.body.appendChild(container);
|
|
const root = ReactDOMClient.createRoot(container);
|
|
try {
|
|
await act(() => {
|
|
root.render(<Parent />);
|
|
});
|
|
buttonRef.click();
|
|
expect(count).toBe(1);
|
|
} finally {
|
|
document.body.removeChild(container);
|
|
}
|
|
});
|
|
|
|
it('allows a DOM element to be used with a string', async () => {
|
|
const element = React.createElement('div', {className: 'foo'});
|
|
const container = document.createElement('div');
|
|
const root = ReactDOMClient.createRoot(container);
|
|
await act(() => {
|
|
root.render(element);
|
|
});
|
|
|
|
const node = container.firstChild;
|
|
expect(node.tagName).toBe('DIV');
|
|
});
|
|
|
|
it('should allow children to be passed as an argument', async () => {
|
|
const container = document.createElement('div');
|
|
const root = ReactDOMClient.createRoot(container);
|
|
await act(() => {
|
|
root.render(React.createElement('div', null, 'child'));
|
|
});
|
|
|
|
const argNode = container.firstChild;
|
|
expect(argNode.innerHTML).toBe('child');
|
|
});
|
|
|
|
it('should overwrite props.children with children argument', async () => {
|
|
const container = document.createElement('div');
|
|
const root = ReactDOMClient.createRoot(container);
|
|
await act(() => {
|
|
root.render(React.createElement('div', {children: 'fakechild'}, 'child'));
|
|
});
|
|
|
|
const conflictNode = container.firstChild;
|
|
expect(conflictNode.innerHTML).toBe('child');
|
|
});
|
|
|
|
/**
|
|
* We need to make sure that updates occur to the actual node that's in the
|
|
* DOM, instead of a stale cache.
|
|
*/
|
|
it('should purge the DOM cache when removing nodes', async () => {
|
|
let container = document.createElement('div');
|
|
let root = ReactDOMClient.createRoot(container);
|
|
await act(() => {
|
|
root.render(
|
|
<div>
|
|
<div key="theDog" className="dog" />,
|
|
<div key="theBird" className="bird" />
|
|
</div>,
|
|
);
|
|
});
|
|
// Warm the cache with theDog
|
|
container = document.createElement('div');
|
|
root = ReactDOMClient.createRoot(container);
|
|
await act(() => {
|
|
root.render(
|
|
<div>
|
|
<div key="theDog" className="dogbeforedelete" />,
|
|
<div key="theBird" className="bird" />,
|
|
</div>,
|
|
);
|
|
});
|
|
// Remove theDog - this should purge the cache
|
|
container = document.createElement('div');
|
|
root = ReactDOMClient.createRoot(container);
|
|
await act(() => {
|
|
root.render(
|
|
<div>
|
|
<div key="theBird" className="bird" />,
|
|
</div>,
|
|
);
|
|
});
|
|
// Now, put theDog back. It's now a different DOM node.
|
|
container = document.createElement('div');
|
|
root = ReactDOMClient.createRoot(container);
|
|
await act(() => {
|
|
root.render(
|
|
<div>
|
|
<div key="theDog" className="dog" />,
|
|
<div key="theBird" className="bird" />,
|
|
</div>,
|
|
);
|
|
});
|
|
// Change the className of theDog. It will use the same element
|
|
container = document.createElement('div');
|
|
root = ReactDOMClient.createRoot(container);
|
|
await act(() => {
|
|
root.render(
|
|
<div>
|
|
<div key="theDog" className="bigdog" />,
|
|
<div key="theBird" className="bird" />,
|
|
</div>,
|
|
);
|
|
});
|
|
|
|
const myDiv = container.firstChild;
|
|
const dog = myDiv.childNodes[0];
|
|
expect(dog.className).toBe('bigdog');
|
|
});
|
|
|
|
// @gate !disableLegacyMode
|
|
it('throws in render() if the mount callback in legacy roots is not a function', async () => {
|
|
spyOnDev(console, 'warn');
|
|
spyOnDev(console, 'error');
|
|
|
|
function Foo() {
|
|
this.a = 1;
|
|
this.b = 2;
|
|
}
|
|
|
|
class A extends React.Component {
|
|
state = {};
|
|
|
|
render() {
|
|
return <div />;
|
|
}
|
|
}
|
|
|
|
const myDiv = document.createElement('div');
|
|
await expect(async () => {
|
|
await act(() => {
|
|
ReactDOM.render(<A />, myDiv, 'no');
|
|
});
|
|
}).rejects.toThrowError(
|
|
'Invalid argument passed as callback. Expected a function. Instead ' +
|
|
'received: no',
|
|
);
|
|
assertConsoleErrorDev(
|
|
[
|
|
'Expected the last optional `callback` argument to be a function. Instead received: no.',
|
|
'Expected the last optional `callback` argument to be a function. Instead received: no.',
|
|
],
|
|
{withoutStack: 2},
|
|
);
|
|
|
|
await expect(async () => {
|
|
await act(() => {
|
|
ReactDOM.render(<A />, myDiv, {foo: 'bar'});
|
|
});
|
|
}).rejects.toThrowError(
|
|
'Invalid argument passed as callback. Expected a function. Instead ' +
|
|
'received: [object Object]',
|
|
);
|
|
assertConsoleErrorDev(
|
|
[
|
|
"Expected the last optional `callback` argument to be a function. Instead received: { foo: 'bar' }",
|
|
"Expected the last optional `callback` argument to be a function. Instead received: { foo: 'bar' }.",
|
|
],
|
|
{withoutStack: 2},
|
|
);
|
|
|
|
await expect(async () => {
|
|
await act(() => {
|
|
ReactDOM.render(<A />, myDiv, new Foo());
|
|
});
|
|
}).rejects.toThrowError(
|
|
'Invalid argument passed as callback. Expected a function. Instead ' +
|
|
'received: [object Object]',
|
|
);
|
|
assertConsoleErrorDev(
|
|
[
|
|
'Expected the last optional `callback` argument to be a function. Instead received: Foo { a: 1, b: 2 }.',
|
|
'Expected the last optional `callback` argument to be a function. Instead received: Foo { a: 1, b: 2 }.',
|
|
],
|
|
{withoutStack: 2},
|
|
);
|
|
});
|
|
|
|
// @gate !disableLegacyMode
|
|
it('throws in render() if the update callback in legacy roots is not a function', async () => {
|
|
function Foo() {
|
|
this.a = 1;
|
|
this.b = 2;
|
|
}
|
|
|
|
class A extends React.Component {
|
|
state = {};
|
|
|
|
render() {
|
|
return <div />;
|
|
}
|
|
}
|
|
|
|
const myDiv = document.createElement('div');
|
|
ReactDOM.render(<A />, myDiv);
|
|
await expect(async () => {
|
|
await act(() => {
|
|
ReactDOM.render(<A />, myDiv, 'no');
|
|
});
|
|
}).rejects.toThrowError(
|
|
'Invalid argument passed as callback. Expected a function. Instead ' +
|
|
'received: no',
|
|
);
|
|
assertConsoleErrorDev(
|
|
[
|
|
'Expected the last optional `callback` argument to be a function. Instead received: no.',
|
|
'Expected the last optional `callback` argument to be a function. Instead received: no.',
|
|
],
|
|
{withoutStack: 2},
|
|
);
|
|
|
|
ReactDOM.render(<A />, myDiv); // Re-mount
|
|
await expect(async () => {
|
|
await act(() => {
|
|
ReactDOM.render(<A />, myDiv, {foo: 'bar'});
|
|
});
|
|
}).rejects.toThrowError(
|
|
'Invalid argument passed as callback. Expected a function. Instead ' +
|
|
'received: [object Object]',
|
|
);
|
|
assertConsoleErrorDev(
|
|
[
|
|
"Expected the last optional `callback` argument to be a function. Instead received: { foo: 'bar' }.",
|
|
"Expected the last optional `callback` argument to be a function. Instead received: { foo: 'bar' }.",
|
|
],
|
|
{withoutStack: 2},
|
|
);
|
|
|
|
ReactDOM.render(<A />, myDiv); // Re-mount
|
|
await expect(async () => {
|
|
await act(() => {
|
|
ReactDOM.render(<A />, myDiv, new Foo());
|
|
});
|
|
}).rejects.toThrowError(
|
|
'Invalid argument passed as callback. Expected a function. Instead ' +
|
|
'received: [object Object]',
|
|
);
|
|
assertConsoleErrorDev(
|
|
[
|
|
'Expected the last optional `callback` argument to be a function. Instead received: Foo { a: 1, b: 2 }.',
|
|
'Expected the last optional `callback` argument to be a function. Instead received: Foo { a: 1, b: 2 }.',
|
|
],
|
|
{withoutStack: 2},
|
|
);
|
|
});
|
|
|
|
it('preserves focus', async () => {
|
|
let input;
|
|
let input2;
|
|
class A extends React.Component {
|
|
render() {
|
|
return (
|
|
<div>
|
|
<input id="one" ref={r => (input = input || r)} />
|
|
{this.props.showTwo && (
|
|
<input id="two" ref={r => (input2 = input2 || r)} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
componentDidUpdate() {
|
|
// Focus should have been restored to the original input
|
|
expect(document.activeElement.id).toBe('one');
|
|
input2.focus();
|
|
expect(document.activeElement.id).toBe('two');
|
|
log.push('input2 focused');
|
|
}
|
|
}
|
|
|
|
const log = [];
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = ReactDOMClient.createRoot(container);
|
|
try {
|
|
await act(() => {
|
|
root.render(<A showTwo={false} />);
|
|
});
|
|
input.focus();
|
|
|
|
// When the second input is added, let's simulate losing focus, which is
|
|
// something that could happen when manipulating DOM nodes (but is hard to
|
|
// deterministically force without relying intensely on React DOM
|
|
// implementation details)
|
|
const div = container.firstChild;
|
|
['appendChild', 'insertBefore'].forEach(name => {
|
|
const mutator = div[name];
|
|
div[name] = function () {
|
|
if (input) {
|
|
input.blur();
|
|
expect(document.activeElement.tagName).toBe('BODY');
|
|
log.push('input2 inserted');
|
|
}
|
|
return mutator.apply(this, arguments);
|
|
};
|
|
});
|
|
|
|
expect(document.activeElement.id).toBe('one');
|
|
await act(() => {
|
|
root.render(<A showTwo={true} />);
|
|
});
|
|
// input2 gets added, which causes input to get blurred. Then
|
|
// componentDidUpdate focuses input2 and that should make it down to here,
|
|
// not get overwritten by focus restoration.
|
|
expect(document.activeElement.id).toBe('two');
|
|
expect(log).toEqual(['input2 inserted', 'input2 focused']);
|
|
} finally {
|
|
document.body.removeChild(container);
|
|
}
|
|
});
|
|
|
|
it('calls focus() on autoFocus elements after they have been mounted to the DOM', async () => {
|
|
const originalFocus = HTMLElement.prototype.focus;
|
|
|
|
try {
|
|
let focusedElement;
|
|
let inputFocusedAfterMount = false;
|
|
|
|
// This test needs to determine that focus is called after mount.
|
|
// Can't check document.activeElement because PhantomJS is too permissive;
|
|
// It doesn't require element to be in the DOM to be focused.
|
|
HTMLElement.prototype.focus = function () {
|
|
focusedElement = this;
|
|
inputFocusedAfterMount = !!this.parentNode;
|
|
};
|
|
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = ReactDOMClient.createRoot(container);
|
|
await act(() => {
|
|
root.render(
|
|
<div>
|
|
<h1>Auto-focus Test</h1>
|
|
<input autoFocus={true} />
|
|
<p>The above input should be focused after mount.</p>
|
|
</div>,
|
|
);
|
|
});
|
|
|
|
expect(inputFocusedAfterMount).toBe(true);
|
|
expect(focusedElement.tagName).toBe('INPUT');
|
|
} finally {
|
|
HTMLElement.prototype.focus = originalFocus;
|
|
}
|
|
});
|
|
|
|
it("shouldn't fire duplicate event handler while handling other nested dispatch", async () => {
|
|
const actual = [];
|
|
|
|
class Wrapper extends React.Component {
|
|
componentDidMount() {
|
|
this.ref1.click();
|
|
}
|
|
|
|
render() {
|
|
return (
|
|
<div>
|
|
<div
|
|
onClick={() => {
|
|
actual.push('1st node clicked');
|
|
this.ref2.click();
|
|
}}
|
|
ref={ref => (this.ref1 = ref)}
|
|
/>
|
|
<div
|
|
onClick={ref => {
|
|
actual.push("2nd node clicked imperatively from 1st's handler");
|
|
}}
|
|
ref={ref => (this.ref2 = ref)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = ReactDOMClient.createRoot(container);
|
|
try {
|
|
await act(() => {
|
|
root.render(<Wrapper />);
|
|
});
|
|
|
|
const expected = [
|
|
'1st node clicked',
|
|
"2nd node clicked imperatively from 1st's handler",
|
|
];
|
|
|
|
expect(actual).toEqual(expected);
|
|
} finally {
|
|
document.body.removeChild(container);
|
|
}
|
|
});
|
|
|
|
it('should not crash with devtools installed', async () => {
|
|
try {
|
|
global.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
|
|
inject: function () {},
|
|
onCommitFiberRoot: function () {},
|
|
onCommitFiberUnmount: function () {},
|
|
supportsFiber: true,
|
|
};
|
|
jest.resetModules();
|
|
React = require('react');
|
|
ReactDOM = require('react-dom');
|
|
class Component extends React.Component {
|
|
render() {
|
|
return <div />;
|
|
}
|
|
}
|
|
const root = ReactDOMClient.createRoot(document.createElement('div'));
|
|
await act(() => {
|
|
root.render(<Component />);
|
|
});
|
|
} finally {
|
|
delete global.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
}
|
|
});
|
|
|
|
it('should not crash calling findDOMNode inside a function component', async () => {
|
|
class Component extends React.Component {
|
|
render() {
|
|
return <div />;
|
|
}
|
|
}
|
|
|
|
const container = document.createElement('div');
|
|
let root = ReactDOMClient.createRoot(container);
|
|
let instance;
|
|
await act(() => {
|
|
root.render(<Component ref={current => (instance = current)} />);
|
|
});
|
|
|
|
const App = () => {
|
|
findDOMNode(instance);
|
|
return <div />;
|
|
};
|
|
|
|
if (__DEV__) {
|
|
root = ReactDOMClient.createRoot(document.createElement('div'));
|
|
await act(() => {
|
|
root.render(<App />);
|
|
});
|
|
}
|
|
});
|
|
|
|
it('reports stacks with re-entrant renderToString() calls on the client', async () => {
|
|
function Child2(props) {
|
|
return <span ariaTypo3="no">{props.children}</span>;
|
|
}
|
|
|
|
function App2() {
|
|
return (
|
|
<Child2>
|
|
{ReactDOMServer.renderToString(<blink ariaTypo2="no" />)}
|
|
</Child2>
|
|
);
|
|
}
|
|
|
|
function Child() {
|
|
return (
|
|
<span ariaTypo4="no">{ReactDOMServer.renderToString(<App2 />)}</span>
|
|
);
|
|
}
|
|
|
|
function ServerEntry() {
|
|
return ReactDOMServer.renderToString(<Child />);
|
|
}
|
|
|
|
function App() {
|
|
return (
|
|
<div>
|
|
<span ariaTypo="no" />
|
|
<ServerEntry />
|
|
<font ariaTypo5="no" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const root = ReactDOMClient.createRoot(document.createElement('div'));
|
|
await act(() => {
|
|
root.render(<App />);
|
|
});
|
|
assertConsoleErrorDev([
|
|
// ReactDOM(App > div > span)
|
|
'Invalid ARIA attribute `ariaTypo`. ARIA attributes follow the pattern aria-* and must be lowercase.\n' +
|
|
' in span (at **)\n' +
|
|
' in App (at **)',
|
|
// ReactDOM(App > div > ServerEntry) >>> ReactDOMServer(Child) >>> ReactDOMServer(App2) >>> ReactDOMServer(blink)
|
|
'Invalid ARIA attribute `ariaTypo2`. ARIA attributes follow the pattern aria-* and must be lowercase.\n' +
|
|
' in blink (at **)',
|
|
// ReactDOM(App > div > ServerEntry) >>> ReactDOMServer(Child) >>> ReactDOMServer(App2 > Child2 > span)
|
|
'Invalid ARIA attribute `ariaTypo3`. ARIA attributes follow the pattern aria-* and must be lowercase.\n' +
|
|
' in span (at **)\n' +
|
|
' in Child2 (at **)\n' +
|
|
' in App2 (at **)',
|
|
// ReactDOM(App > div > ServerEntry) >>> ReactDOMServer(Child > span)
|
|
'Invalid ARIA attribute `ariaTypo4`. ARIA attributes follow the pattern aria-* and must be lowercase.\n' +
|
|
' in span (at **)\n' +
|
|
' in Child (at **)',
|
|
// ReactDOM(App > div > font)
|
|
'Invalid ARIA attribute `ariaTypo5`. ARIA attributes follow the pattern aria-* and must be lowercase.\n' +
|
|
' in font (at **)\n' +
|
|
' in App (at **)',
|
|
]);
|
|
});
|
|
|
|
it('should render root host components into body scope when the container is a Document', async () => {
|
|
function App({phase}) {
|
|
return (
|
|
<>
|
|
{phase < 1 ? null : <div>..before</div>}
|
|
{phase < 3 ? <div>before</div> : null}
|
|
{phase < 2 ? null : <div>before..</div>}
|
|
<html lang="en">
|
|
<head data-h="">
|
|
{phase < 1 ? null : <meta itemProp="" content="..head" />}
|
|
{phase < 3 ? <meta itemProp="" content="head" /> : null}
|
|
{phase < 2 ? null : <meta itemProp="" content="head.." />}
|
|
</head>
|
|
<body data-b="">
|
|
{phase < 1 ? null : <div>..inside</div>}
|
|
{phase < 3 ? <div>inside</div> : null}
|
|
{phase < 2 ? null : <div>inside..</div>}
|
|
</body>
|
|
</html>
|
|
{phase < 1 ? null : <div>..after</div>}
|
|
{phase < 3 ? <div>after</div> : null}
|
|
{phase < 2 ? null : <div>after..</div>}
|
|
</>
|
|
);
|
|
}
|
|
|
|
const root = ReactDOMClient.createRoot(document);
|
|
await act(() => {
|
|
root.render(<App phase={0} />);
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html lang="en"><head data-h=""><meta itemprop="" content="head"></head><body data-b=""><div>before</div><div>inside</div><div>after</div></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
root.render(<App phase={1} />);
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html lang="en"><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"></head><body data-b=""><div>..before</div><div>before</div><div>..inside</div><div>inside</div><div>..after</div><div>after</div></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
root.render(<App phase={2} />);
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html lang="en"><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"><meta itemprop="" content="head.."></head><body data-b=""><div>..before</div><div>before</div><div>before..</div><div>..inside</div><div>inside</div><div>inside..</div><div>..after</div><div>after</div><div>after..</div></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
root.render(<App phase={3} />);
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html lang="en"><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head.."></head><body data-b=""><div>..before</div><div>before..</div><div>..inside</div><div>inside..</div><div>..after</div><div>after..</div></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
root.unmount();
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html><head></head><body></body></html>',
|
|
);
|
|
});
|
|
|
|
it('should render root host components into body scope when the container is a the <html> tag', async () => {
|
|
function App({phase}) {
|
|
return (
|
|
<>
|
|
{phase < 1 ? null : <div>..before</div>}
|
|
{phase < 3 ? <div>before</div> : null}
|
|
{phase < 2 ? null : <div>before..</div>}
|
|
<head data-h="">
|
|
{phase < 1 ? null : <meta itemProp="" content="..head" />}
|
|
{phase < 3 ? <meta itemProp="" content="head" /> : null}
|
|
{phase < 2 ? null : <meta itemProp="" content="head.." />}
|
|
</head>
|
|
<body data-b="">
|
|
{phase < 1 ? null : <div>..inside</div>}
|
|
{phase < 3 ? <div>inside</div> : null}
|
|
{phase < 2 ? null : <div>inside..</div>}
|
|
</body>
|
|
{phase < 1 ? null : <div>..after</div>}
|
|
{phase < 3 ? <div>after</div> : null}
|
|
{phase < 2 ? null : <div>after..</div>}
|
|
</>
|
|
);
|
|
}
|
|
|
|
const root = ReactDOMClient.createRoot(document.documentElement);
|
|
await act(() => {
|
|
root.render(<App phase={0} />);
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html><head data-h=""><meta itemprop="" content="head"></head><body data-b=""><div>before</div><div>inside</div><div>after</div></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
root.render(<App phase={1} />);
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"></head><body data-b=""><div>..before</div><div>before</div><div>..inside</div><div>inside</div><div>..after</div><div>after</div></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
root.render(<App phase={2} />);
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"><meta itemprop="" content="head.."></head><body data-b=""><div>..before</div><div>before</div><div>before..</div><div>..inside</div><div>inside</div><div>inside..</div><div>..after</div><div>after</div><div>after..</div></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
root.render(<App phase={3} />);
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head.."></head><body data-b=""><div>..before</div><div>before..</div><div>..inside</div><div>inside..</div><div>..after</div><div>after..</div></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
root.unmount();
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html><head></head><body></body></html>',
|
|
);
|
|
});
|
|
|
|
it('should render root host components into body scope when the container is a the <body> tag', async () => {
|
|
function App({phase}) {
|
|
return (
|
|
<>
|
|
{phase < 1 ? null : <div>..before</div>}
|
|
{phase < 3 ? <div>before</div> : null}
|
|
{phase < 2 ? null : <div>before..</div>}
|
|
<head data-h="">
|
|
{phase < 1 ? null : <meta itemProp="" content="..head" />}
|
|
{phase < 3 ? <meta itemProp="" content="head" /> : null}
|
|
{phase < 2 ? null : <meta itemProp="" content="head.." />}
|
|
</head>
|
|
{phase < 1 ? null : <div>..inside</div>}
|
|
{phase < 3 ? <div>inside</div> : null}
|
|
{phase < 2 ? null : <div>inside..</div>}
|
|
{phase < 1 ? null : <div>..after</div>}
|
|
{phase < 3 ? <div>after</div> : null}
|
|
{phase < 2 ? null : <div>after..</div>}
|
|
</>
|
|
);
|
|
}
|
|
|
|
const root = ReactDOMClient.createRoot(document.body);
|
|
await act(() => {
|
|
root.render(<App phase={0} />);
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html><head data-h=""><meta itemprop="" content="head"></head><body><div>before</div><div>inside</div><div>after</div></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
root.render(<App phase={1} />);
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"></head><body><div>..before</div><div>before</div><div>..inside</div><div>inside</div><div>..after</div><div>after</div></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
root.render(<App phase={2} />);
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"><meta itemprop="" content="head.."></head><body><div>..before</div><div>before</div><div>before..</div><div>..inside</div><div>inside</div><div>inside..</div><div>..after</div><div>after</div><div>after..</div></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
root.render(<App phase={3} />);
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head.."></head><body><div>..before</div><div>before..</div><div>..inside</div><div>inside..</div><div>..after</div><div>after..</div></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
root.unmount();
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html><head></head><body></body></html>',
|
|
);
|
|
});
|
|
|
|
it('should render children of <head> into the document head even when the container is inside the document body', async () => {
|
|
function App({phase}) {
|
|
return (
|
|
<>
|
|
<div>before</div>
|
|
<head data-h="">
|
|
{phase < 1 ? null : <meta itemProp="" content="..head" />}
|
|
{phase < 3 ? <meta itemProp="" content="head" /> : null}
|
|
{phase < 2 ? null : <meta itemProp="" content="head.." />}
|
|
</head>
|
|
<div>after</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const container = document.createElement('main');
|
|
document.body.append(container);
|
|
const root = ReactDOMClient.createRoot(container);
|
|
await act(() => {
|
|
root.render(<App phase={0} />);
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html><head data-h=""><meta itemprop="" content="head"></head><body><main><div>before</div><div>after</div></main></body></html>',
|
|
);
|
|
|
|
// @TODO remove this warning check when we loosen the tag nesting restrictions to allow arbitrary tags at the
|
|
// root of the application
|
|
assertConsoleErrorDev(['In HTML, <head> cannot be a child of <main>']);
|
|
|
|
await act(() => {
|
|
root.render(<App phase={1} />);
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"></head><body><main><div>before</div><div>after</div></main></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
root.render(<App phase={2} />);
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"><meta itemprop="" content="head.."></head><body><main><div>before</div><div>after</div></main></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
root.render(<App phase={3} />);
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head.."></head><body><main><div>before</div><div>after</div></main></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
root.unmount();
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html><head></head><body><main></main></body></html>',
|
|
);
|
|
});
|
|
|
|
it('can render a Suspense boundary above the <html> tag', async () => {
|
|
let suspendOnNewPromise;
|
|
let resolveCurrentPromise;
|
|
let currentPromise;
|
|
function createNewPromise() {
|
|
currentPromise = new Promise(r => {
|
|
resolveCurrentPromise = r;
|
|
});
|
|
return currentPromise;
|
|
}
|
|
createNewPromise();
|
|
function Comp() {
|
|
const [promise, setPromise] = React.useState(currentPromise);
|
|
suspendOnNewPromise = () => {
|
|
setPromise(createNewPromise());
|
|
};
|
|
React.use(promise);
|
|
return null;
|
|
}
|
|
|
|
const fallback = (
|
|
<html data-fallback="">
|
|
<body data-fallback="">
|
|
<div>fallback</div>
|
|
</body>
|
|
</html>
|
|
);
|
|
|
|
const main = (
|
|
<html lang="en">
|
|
<head>
|
|
<meta itemProp="" content="primary" />
|
|
</head>
|
|
<body>
|
|
<div>
|
|
<Message />
|
|
</div>
|
|
</body>
|
|
</html>
|
|
);
|
|
|
|
let suspendOnNewMessage;
|
|
let currentMessage;
|
|
let resolveCurrentMessage;
|
|
function createNewMessage() {
|
|
currentMessage = new Promise(r => {
|
|
resolveCurrentMessage = r;
|
|
});
|
|
return currentMessage;
|
|
}
|
|
createNewMessage();
|
|
resolveCurrentMessage('hello world');
|
|
function Message() {
|
|
const [pendingMessage, setPendingMessage] =
|
|
React.useState(currentMessage);
|
|
suspendOnNewMessage = () => {
|
|
setPendingMessage(createNewMessage());
|
|
};
|
|
return React.use(pendingMessage);
|
|
}
|
|
|
|
function App() {
|
|
return (
|
|
<React.Suspense fallback={fallback}>
|
|
<Comp />
|
|
{main}
|
|
</React.Suspense>
|
|
);
|
|
}
|
|
|
|
const root = ReactDOMClient.createRoot(document);
|
|
await act(() => {
|
|
root.render(<App />);
|
|
});
|
|
// The initial render is blocked by promiseA so we see the fallback Document
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html data-fallback=""><head></head><body data-fallback=""><div>fallback</div></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
resolveCurrentPromise();
|
|
});
|
|
// When promiseA resolves we see the primary Document
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html lang="en"><head><meta itemprop="" content="primary"></head><body><div>hello world</div></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
suspendOnNewPromise();
|
|
});
|
|
// When we switch to rendering ComponentB synchronously we have to put the Document back into fallback
|
|
// The primary content remains hidden until promiseB resolves
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html data-fallback=""><head><meta itemprop="" content="primary" style="display: none;"></head><body data-fallback=""><div style="display: none;">hello world</div><div>fallback</div></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
resolveCurrentPromise();
|
|
});
|
|
// When promiseB resolves we see the new primary content inside the primary Document
|
|
// style attributes stick around after being unhidden by the Suspense boundary
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html lang="en"><head><meta itemprop="" content="primary" style=""></head><body><div style="">hello world</div></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
React.startTransition(() => {
|
|
suspendOnNewPromise();
|
|
});
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html lang="en"><head><meta itemprop="" content="primary" style=""></head><body><div style="">hello world</div></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
resolveCurrentPromise();
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html lang="en"><head><meta itemprop="" content="primary" style=""></head><body><div style="">hello world</div></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
suspendOnNewMessage();
|
|
});
|
|
// When we update the message itself we will be causing updates on the primary content of the Suspense boundary.
|
|
// The reason we also test for this is to make sure we don't double acquire the document singletons while
|
|
// disappearing and reappearing layout effects
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html data-fallback=""><head><meta itemprop="" content="primary" style="display: none;"></head><body data-fallback=""><div style="display: none;">hello world</div><div>fallback</div></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
resolveCurrentMessage('hello you!');
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html lang="en"><head><meta itemprop="" content="primary" style=""></head><body><div style="">hello you!</div></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
React.startTransition(() => {
|
|
suspendOnNewMessage();
|
|
});
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html lang="en"><head><meta itemprop="" content="primary" style=""></head><body><div style="">hello you!</div></body></html>',
|
|
);
|
|
|
|
await act(() => {
|
|
resolveCurrentMessage('goodbye!');
|
|
});
|
|
expect(document.documentElement.outerHTML).toBe(
|
|
'<html lang="en"><head><meta itemprop="" content="primary" style=""></head><body><div style="">goodbye!</div></body></html>',
|
|
);
|
|
});
|
|
});
|