mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
481 lines
14 KiB
JavaScript
481 lines
14 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 = require('react');
|
|
let ReactDOM = require('react-dom');
|
|
let ReactDOMClient = require('react-dom/client');
|
|
let ReactDOMServer = require('react-dom/server');
|
|
let Scheduler = require('scheduler');
|
|
let act;
|
|
let useEffect;
|
|
let assertLog;
|
|
let waitForAll;
|
|
let assertConsoleErrorDev;
|
|
|
|
describe('ReactDOMRoot', () => {
|
|
let container;
|
|
|
|
beforeEach(() => {
|
|
jest.resetModules();
|
|
container = document.createElement('div');
|
|
React = require('react');
|
|
ReactDOM = require('react-dom');
|
|
ReactDOMClient = require('react-dom/client');
|
|
ReactDOMServer = require('react-dom/server');
|
|
Scheduler = require('scheduler');
|
|
act = require('internal-test-utils').act;
|
|
assertConsoleErrorDev =
|
|
require('internal-test-utils').assertConsoleErrorDev;
|
|
useEffect = React.useEffect;
|
|
|
|
const InternalTestUtils = require('internal-test-utils');
|
|
assertLog = InternalTestUtils.assertLog;
|
|
waitForAll = InternalTestUtils.waitForAll;
|
|
});
|
|
|
|
it('renders children', async () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(<div>Hi</div>);
|
|
await waitForAll([]);
|
|
expect(container.textContent).toEqual('Hi');
|
|
});
|
|
|
|
it('warns if a callback parameter is provided to render', async () => {
|
|
const callback = jest.fn();
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(<div>Hi</div>, callback);
|
|
assertConsoleErrorDev(
|
|
[
|
|
'does not support the second callback argument. ' +
|
|
'To execute a side effect after rendering, declare it in a component body with useEffect().',
|
|
],
|
|
{withoutStack: true},
|
|
);
|
|
await waitForAll([]);
|
|
expect(callback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('warn if a object is passed to root.render(...)', async () => {
|
|
function App() {
|
|
return 'Child';
|
|
}
|
|
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(<App />, {});
|
|
assertConsoleErrorDev(
|
|
[
|
|
'You passed a second argument to root.render(...) but it only accepts ' +
|
|
'one argument.',
|
|
],
|
|
{
|
|
withoutStack: true,
|
|
},
|
|
);
|
|
});
|
|
|
|
it('warn if a container is passed to root.render(...)', async () => {
|
|
function App() {
|
|
return 'Child';
|
|
}
|
|
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(<App />, container);
|
|
assertConsoleErrorDev(
|
|
[
|
|
'You passed a container to the second argument of root.render(...). ' +
|
|
"You don't need to pass it again since you already passed it to create " +
|
|
'the root.',
|
|
],
|
|
{
|
|
withoutStack: true,
|
|
},
|
|
);
|
|
});
|
|
|
|
it('warns if a callback parameter is provided to unmount', async () => {
|
|
const callback = jest.fn();
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(<div>Hi</div>);
|
|
root.unmount(callback);
|
|
assertConsoleErrorDev(
|
|
[
|
|
'does not support a callback argument. ' +
|
|
'To execute a side effect after rendering, declare it in a component body with useEffect().',
|
|
],
|
|
{withoutStack: true},
|
|
);
|
|
await waitForAll([]);
|
|
expect(callback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('unmounts children', async () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(<div>Hi</div>);
|
|
await waitForAll([]);
|
|
expect(container.textContent).toEqual('Hi');
|
|
root.unmount();
|
|
await waitForAll([]);
|
|
expect(container.textContent).toEqual('');
|
|
});
|
|
|
|
it('can be immediately unmounted', async () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
await act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it('supports hydration', async () => {
|
|
const markup = await new Promise(resolve =>
|
|
resolve(
|
|
ReactDOMServer.renderToString(
|
|
<div>
|
|
<span className="extra" />
|
|
</div>,
|
|
),
|
|
),
|
|
);
|
|
|
|
// Does not hydrate by default
|
|
const container1 = document.createElement('div');
|
|
container1.innerHTML = markup;
|
|
const root1 = ReactDOMClient.createRoot(container1);
|
|
root1.render(
|
|
<div>
|
|
<span />
|
|
</div>,
|
|
);
|
|
await waitForAll([]);
|
|
|
|
const container2 = document.createElement('div');
|
|
container2.innerHTML = markup;
|
|
ReactDOMClient.hydrateRoot(
|
|
container2,
|
|
<div>
|
|
<span />
|
|
</div>,
|
|
);
|
|
await waitForAll([]);
|
|
assertConsoleErrorDev([
|
|
"A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. " +
|
|
"This won't be patched up. This can happen if a SSR-ed Client Component used:\n" +
|
|
'\n' +
|
|
"- A server/client branch `if (typeof window !== 'undefined')`.\n" +
|
|
"- Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.\n" +
|
|
"- Date formatting in a user's locale which doesn't match the server.\n" +
|
|
'- External changing data without sending a snapshot of it along with the HTML.\n' +
|
|
'- Invalid HTML tag nesting.\n' +
|
|
'\n' +
|
|
'It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.\n' +
|
|
'\n' +
|
|
'https://react.dev/link/hydration-mismatch\n' +
|
|
'\n' +
|
|
' <div>\n' +
|
|
' <span\n' +
|
|
'- className="extra"\n' +
|
|
' >\n' +
|
|
'\n in span (at **)',
|
|
]);
|
|
});
|
|
|
|
it('clears existing children', async () => {
|
|
container.innerHTML = '<div>a</div><div>b</div>';
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(
|
|
<div>
|
|
<span>c</span>
|
|
<span>d</span>
|
|
</div>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(container.textContent).toEqual('cd');
|
|
root.render(
|
|
<div>
|
|
<span>d</span>
|
|
<span>c</span>
|
|
</div>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(container.textContent).toEqual('dc');
|
|
});
|
|
|
|
it('throws a good message on invalid containers', () => {
|
|
expect(() => {
|
|
ReactDOMClient.createRoot(<div>Hi</div>);
|
|
}).toThrow('Target container is not a DOM element.');
|
|
});
|
|
|
|
it('warns when creating two roots managing the same container', () => {
|
|
ReactDOMClient.createRoot(container);
|
|
ReactDOMClient.createRoot(container);
|
|
assertConsoleErrorDev(
|
|
[
|
|
'You are calling ReactDOMClient.createRoot() on a container that ' +
|
|
'has already been passed to createRoot() before. Instead, call ' +
|
|
'root.render() on the existing root instead if you want to update it.',
|
|
],
|
|
{withoutStack: true},
|
|
);
|
|
});
|
|
|
|
it('does not warn when creating second root after first one is unmounted', async () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.unmount();
|
|
await waitForAll([]);
|
|
ReactDOMClient.createRoot(container); // No warning
|
|
});
|
|
|
|
it('warns if creating a root on the document.body', async () => {
|
|
// we no longer expect an error for this if float is enabled
|
|
ReactDOMClient.createRoot(document.body);
|
|
});
|
|
|
|
it('warns if updating a root that has had its contents removed', async () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(<div>Hi</div>);
|
|
await waitForAll([]);
|
|
container.innerHTML = '';
|
|
|
|
// When either of these flags are on this validation is turned off so we
|
|
// expect there to be no warnings
|
|
root.render(<div>Hi</div>);
|
|
});
|
|
|
|
it('should render different components in same root', async () => {
|
|
document.body.appendChild(container);
|
|
const root = ReactDOMClient.createRoot(container);
|
|
|
|
await act(() => {
|
|
root.render(<div />);
|
|
});
|
|
expect(container.firstChild.nodeName).toBe('DIV');
|
|
|
|
await act(() => {
|
|
root.render(<span />);
|
|
});
|
|
expect(container.firstChild.nodeName).toBe('SPAN');
|
|
});
|
|
|
|
it('should not warn if mounting into non-empty node', async () => {
|
|
container.innerHTML = '<div></div>';
|
|
const root = ReactDOMClient.createRoot(container);
|
|
await act(() => {
|
|
root.render(<div />);
|
|
});
|
|
|
|
expect(true).toBe(true);
|
|
});
|
|
|
|
it('should reuse markup if rendering to the same target twice', async () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
await act(() => {
|
|
root.render(<div />);
|
|
});
|
|
const firstElm = container.firstChild;
|
|
await act(() => {
|
|
root.render(<div />);
|
|
});
|
|
|
|
expect(firstElm).toBe(container.firstChild);
|
|
});
|
|
|
|
it('should unmount and remount if the key changes', async () => {
|
|
function Component({text}) {
|
|
useEffect(() => {
|
|
Scheduler.log('Mount');
|
|
|
|
return () => {
|
|
Scheduler.log('Unmount');
|
|
};
|
|
}, []);
|
|
|
|
return <span>{text}</span>;
|
|
}
|
|
|
|
const root = ReactDOMClient.createRoot(container);
|
|
|
|
await act(() => {
|
|
root.render(<Component text="orange" key="A" />);
|
|
});
|
|
expect(container.firstChild.innerHTML).toBe('orange');
|
|
assertLog(['Mount']);
|
|
|
|
// If we change the key, the component is unmounted and remounted
|
|
await act(() => {
|
|
root.render(<Component text="green" key="B" />);
|
|
});
|
|
expect(container.firstChild.innerHTML).toBe('green');
|
|
assertLog(['Unmount', 'Mount']);
|
|
|
|
// But if we don't change the key, the component instance is reused
|
|
await act(() => {
|
|
root.render(<Component text="blue" key="B" />);
|
|
});
|
|
expect(container.firstChild.innerHTML).toBe('blue');
|
|
assertLog([]);
|
|
});
|
|
|
|
it('throws if unmounting a root that has had its contents removed', async () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
await act(() => {
|
|
root.render(<div>Hi</div>);
|
|
});
|
|
container.innerHTML = '';
|
|
|
|
await expect(async () => {
|
|
await act(() => {
|
|
root.unmount();
|
|
});
|
|
}).rejects.toThrow('The node to be removed is not a child of this node.');
|
|
});
|
|
|
|
it('unmount is synchronous', async () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
await act(() => {
|
|
root.render('Hi');
|
|
});
|
|
expect(container.textContent).toEqual('Hi');
|
|
|
|
await act(() => {
|
|
root.unmount();
|
|
// Should have already unmounted
|
|
expect(container.textContent).toEqual('');
|
|
});
|
|
});
|
|
|
|
it('throws if an unmounted root is updated', async () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
await act(() => {
|
|
root.render('Hi');
|
|
});
|
|
expect(container.textContent).toEqual('Hi');
|
|
|
|
root.unmount();
|
|
|
|
expect(() => root.render("I'm back")).toThrow(
|
|
'Cannot update an unmounted root.',
|
|
);
|
|
});
|
|
|
|
it('warns if root is unmounted inside an effect', async () => {
|
|
const container1 = document.createElement('div');
|
|
const root1 = ReactDOMClient.createRoot(container1);
|
|
const container2 = document.createElement('div');
|
|
const root2 = ReactDOMClient.createRoot(container2);
|
|
|
|
function App({step}) {
|
|
useEffect(() => {
|
|
if (step === 2) {
|
|
root2.unmount();
|
|
}
|
|
}, [step]);
|
|
return 'Hi';
|
|
}
|
|
|
|
await act(() => {
|
|
root1.render(<App step={1} />);
|
|
});
|
|
expect(container1.textContent).toEqual('Hi');
|
|
|
|
ReactDOM.flushSync(() => {
|
|
root1.render(<App step={2} />);
|
|
});
|
|
assertConsoleErrorDev([
|
|
'Attempted to synchronously unmount a root while React was already rendering. ' +
|
|
'React cannot finish unmounting the root until the current render has completed, ' +
|
|
'which may lead to a race condition.\n' +
|
|
' in App (at **)',
|
|
]);
|
|
});
|
|
|
|
// @gate disableCommentsAsDOMContainers
|
|
it('errors if container is a comment node', () => {
|
|
// This is an old feature used by www. Disabled in the open source build.
|
|
const div = document.createElement('div');
|
|
div.innerHTML = '<!-- react-mount-point-unstable -->';
|
|
const commentNode = div.childNodes[0];
|
|
|
|
expect(() => ReactDOMClient.createRoot(commentNode)).toThrow(
|
|
'Target container is not a DOM element.',
|
|
);
|
|
expect(() => ReactDOMClient.hydrateRoot(commentNode)).toThrow(
|
|
'Target container is not a DOM element.',
|
|
);
|
|
});
|
|
|
|
it('warn if no children passed to hydrateRoot', async () => {
|
|
ReactDOMClient.hydrateRoot(container);
|
|
assertConsoleErrorDev(
|
|
[
|
|
'Must provide initial children as second argument to hydrateRoot. ' +
|
|
'Example usage: hydrateRoot(domContainer, <App />)',
|
|
],
|
|
{withoutStack: true},
|
|
);
|
|
});
|
|
|
|
it('warn if JSX passed to createRoot', async () => {
|
|
function App() {
|
|
return 'Child';
|
|
}
|
|
|
|
ReactDOMClient.createRoot(container, <App />);
|
|
assertConsoleErrorDev(
|
|
[
|
|
'You passed a JSX element to createRoot. You probably meant to call root.render instead. ' +
|
|
'Example usage:\n' +
|
|
'\n' +
|
|
' let root = createRoot(domContainer);\n' +
|
|
' root.render(<App />);',
|
|
],
|
|
{
|
|
withoutStack: true,
|
|
},
|
|
);
|
|
});
|
|
|
|
it('warns when given a function', () => {
|
|
function Component() {
|
|
return <div />;
|
|
}
|
|
|
|
const root = ReactDOMClient.createRoot(document.createElement('div'));
|
|
|
|
ReactDOM.flushSync(() => {
|
|
root.render(Component);
|
|
});
|
|
assertConsoleErrorDev(
|
|
[
|
|
'Functions are not valid as a React child. ' +
|
|
'This may happen if you return Component instead of <Component /> from render. ' +
|
|
'Or maybe you meant to call this function rather than return it.\n' +
|
|
' root.render(Component)',
|
|
],
|
|
{withoutStack: true},
|
|
);
|
|
});
|
|
|
|
it('warns when given a symbol', () => {
|
|
const root = ReactDOMClient.createRoot(document.createElement('div'));
|
|
|
|
ReactDOM.flushSync(() => {
|
|
root.render(Symbol('foo'));
|
|
});
|
|
assertConsoleErrorDev(
|
|
[
|
|
'Symbols are not valid as a React child.\n' +
|
|
' root.render(Symbol(foo))',
|
|
],
|
|
{withoutStack: true},
|
|
);
|
|
});
|
|
});
|