Files
react/packages/react-dom/src/__tests__/ReactDOMFiber-test.js
T
Sebastian Markbåge 6786563f3c [Fiber] Don't Rethrow Errors at the Root (#28627)
Stacked on top of #28498 for test fixes.

### Don't Rethrow

When we started React it was 1:1 setState calls a series of renders and
if they error, it errors where the setState was called. Simple. However,
then batching came and the error actually got thrown somewhere else.
With concurrent mode, it's not even possible to get setState itself to
throw anymore.

In fact, all APIs that can rethrow out of React are executed either at
the root of the scheduler or inside a DOM event handler.
If you throw inside a React.startTransition callback that's sync, then
that will bubble out of the startTransition but if you throw inside an
async callback or a useTransition we now need to handle it at the hook
site. So in 19 we need to make all React.startTransition swallow the
error (and report them to reportError).

The only one remaining that can throw is flushSync but it doesn't really
make sense for it to throw at the callsite neither because batching.
Just because something rendered in this flush doesn't mean it was
rendered due to what was just scheduled and doesn't mean that it should
abort any of the remaining code afterwards. setState is fire and forget.
It's send an instruction elsewhere, it's not part of the current
imperative code.

Error boundaries never rethrow. Since you should really always have
error boundaries, most of the time, it wouldn't rethrow anyway.

Rethrowing also actually currently drops errors on the floor since we
can only rethrow the first error, so to avoid that we'd need to call
reportError anyway. This happens in RN events.

The other issue with rethrowing is that it logs an extra console.error.
Since we're not sure that user code will actually log it anywhere we
still log it too just like we do with errors inside error boundaries
which leads all of these to log twice.
The goal of this PR is to never rethrow out of React instead, errors
outside of error boundaries get logged to reportError. Event system
errors too.

### Breaking Changes

The main thing this affects is testing where you want to inspect the
errors thrown. To make it easier to port, if you're inside `act` we
track the error into act in an aggregate error and then rethrow it at
the root of `act`. Unlike before though, if you flush synchronously
inside of act it'll still continue until the end of act before
rethrowing.

I expect most user code breakages would be to migrate from `flushSync`
to `act` if you assert on throwing.

However, in the React repo we also have `internalAct` and the
`waitForThrow` helpers. Since these have to use public production
implementations we track these using the global onerror or process
uncaughtException. Unlike regular act, includes both event handler
errors and onRecoverableError by default too. Not just render/commit
errors. So I had to account for that in our tests.

We restore logging an extra log for uncaught errors after the main log
with the component stack in it. We use `console.warn`. This is not yet
ignorable if you preventDefault to the main error event. To avoid
confusion if you don't end up logging the error to console I just added
`An error occurred`.

### Polyfill

All browsers we support really supports `reportError` but not all test
and server environments do, so I implemented a polyfill for browser and
node in `shared/reportGlobalError`. I don't love that this is included
in all builds and gets duplicated into isomorphic even though it's not
actually needed in production. Maybe in the future we can require a
polyfill for this.

### Follow Ups

In a follow up, I'll make caught vs uncaught error handling be
configurable too.

---------

Co-authored-by: Ricky Hanlon <rickhanlonii@gmail.com>
2024-03-26 23:44:07 -04:00

1271 lines
34 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 PropTypes;
let ReactDOMClient;
let root;
let Scheduler;
let act;
let assertLog;
describe('ReactDOMFiber', () => {
let container;
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactDOM = require('react-dom');
PropTypes = require('prop-types');
ReactDOMClient = require('react-dom/client');
Scheduler = require('scheduler');
act = require('internal-test-utils').act;
assertLog = require('internal-test-utils').assertLog;
container = document.createElement('div');
document.body.appendChild(container);
root = ReactDOMClient.createRoot(container);
});
afterEach(() => {
document.body.removeChild(container);
container = null;
jest.restoreAllMocks();
});
it('should render strings as children', async () => {
const Box = ({value}) => <div>{value}</div>;
await act(async () => {
root.render(<Box value="foo" />);
});
expect(container.textContent).toEqual('foo');
});
it('should render numbers as children', async () => {
const Box = ({value}) => <div>{value}</div>;
await act(async () => {
root.render(<Box value={10} />);
});
expect(container.textContent).toEqual('10');
});
// @gate enableBigIntSupport
it('should render bigints as children', async () => {
const Box = ({value}) => <div>{value}</div>;
await act(async () => {
root.render(<Box value={10n} />);
});
expect(container.textContent).toEqual('10');
});
it('should call an effect after mount/update (replacing render callback pattern)', async () => {
function Component() {
React.useEffect(() => {
Scheduler.log('Callback');
});
return <div>Foo</div>;
}
// mounting phase
await act(async () => {
root.render(<Component />);
});
assertLog(['Callback']);
// updating phase
await act(async () => {
root.render(<Component />);
});
assertLog(['Callback']);
});
it('should call an effect when the same element is re-rendered (replacing render callback pattern)', async () => {
function Component({prop}) {
React.useEffect(() => {
Scheduler.log('Callback');
});
return <div>{prop}</div>;
}
// mounting phase
await act(async () => {
root.render(<Component prop="Foo" />);
});
assertLog(['Callback']);
// updating phase
await act(async () => {
root.render(<Component prop="Bar" />);
});
assertLog(['Callback']);
});
it('should render a component returning strings directly from render', async () => {
const Text = ({value}) => value;
await act(async () => {
root.render(<Text value="foo" />);
});
expect(container.textContent).toEqual('foo');
});
it('should render a component returning numbers directly from render', async () => {
const Text = ({value}) => value;
await act(async () => {
root.render(<Text value={10} />);
});
expect(container.textContent).toEqual('10');
});
it('renders an empty fragment', async () => {
const Div = () => <div />;
const EmptyFragment = () => <></>;
const NonEmptyFragment = () => (
<>
<Div />
</>
);
await act(async () => {
root.render(<EmptyFragment />);
});
expect(container.firstChild).toBe(null);
await act(async () => {
root.render(<NonEmptyFragment />);
});
expect(container.firstChild.tagName).toBe('DIV');
await act(async () => {
root.render(<EmptyFragment />);
});
expect(container.firstChild).toBe(null);
await act(async () => {
root.render(<Div />);
});
expect(container.firstChild.tagName).toBe('DIV');
await act(async () => {
root.render(<EmptyFragment />);
});
expect(container.firstChild).toBe(null);
});
let svgEls, htmlEls, mathEls;
const expectSVG = {ref: el => svgEls.push(el)};
const expectHTML = {ref: el => htmlEls.push(el)};
const expectMath = {ref: el => mathEls.push(el)};
const usePortal = function (tree) {
return ReactDOM.createPortal(tree, document.createElement('div'));
};
const assertNamespacesMatch = async function (tree) {
const testContainer = document.createElement('div');
svgEls = [];
htmlEls = [];
mathEls = [];
const testRoot = ReactDOMClient.createRoot(testContainer);
await act(async () => {
testRoot.render(tree);
});
svgEls.forEach(el => {
expect(el.namespaceURI).toBe('http://www.w3.org/2000/svg');
});
htmlEls.forEach(el => {
expect(el.namespaceURI).toBe('http://www.w3.org/1999/xhtml');
});
mathEls.forEach(el => {
expect(el.namespaceURI).toBe('http://www.w3.org/1998/Math/MathML');
});
testRoot.unmount();
expect(testContainer.innerHTML).toBe('');
};
it('should render one portal', async () => {
const portalContainer = document.createElement('div');
await act(() => {
root.render(
<div>{ReactDOM.createPortal(<div>portal</div>, portalContainer)}</div>,
);
});
expect(portalContainer.innerHTML).toBe('<div>portal</div>');
expect(container.innerHTML).toBe('<div></div>');
root.unmount();
expect(portalContainer.innerHTML).toBe('');
expect(container.innerHTML).toBe('');
});
it('should render many portals', async () => {
const portalContainer1 = document.createElement('div');
const portalContainer2 = document.createElement('div');
class Child extends React.Component {
componentDidMount() {
Scheduler.log(`${this.props.name} componentDidMount`);
}
componentDidUpdate() {
Scheduler.log(`${this.props.name} componentDidUpdate`);
}
componentWillUnmount() {
Scheduler.log(`${this.props.name} componentWillUnmount`);
}
render() {
return <div>{this.props.name}</div>;
}
}
class Parent extends React.Component {
componentDidMount() {
Scheduler.log(`Parent:${this.props.step} componentDidMount`);
}
componentDidUpdate() {
Scheduler.log(`Parent:${this.props.step} componentDidUpdate`);
}
componentWillUnmount() {
Scheduler.log(`Parent:${this.props.step} componentWillUnmount`);
}
render() {
const {step} = this.props;
return [
<Child key="a" name={`normal[0]:${step}`} />,
ReactDOM.createPortal(
<Child key="b" name={`portal1[0]:${step}`} />,
portalContainer1,
),
<Child key="c" name={`normal[1]:${step}`} />,
ReactDOM.createPortal(
[
<Child key="d" name={`portal2[0]:${step}`} />,
<Child key="e" name={`portal2[1]:${step}`} />,
],
portalContainer2,
),
];
}
}
await act(() => {
root.render(<Parent step="a" />);
});
expect(portalContainer1.innerHTML).toBe('<div>portal1[0]:a</div>');
expect(portalContainer2.innerHTML).toBe(
'<div>portal2[0]:a</div><div>portal2[1]:a</div>',
);
expect(container.innerHTML).toBe(
'<div>normal[0]:a</div><div>normal[1]:a</div>',
);
assertLog([
'normal[0]:a componentDidMount',
'portal1[0]:a componentDidMount',
'normal[1]:a componentDidMount',
'portal2[0]:a componentDidMount',
'portal2[1]:a componentDidMount',
'Parent:a componentDidMount',
]);
await act(() => {
root.render(<Parent step="b" />);
});
expect(portalContainer1.innerHTML).toBe('<div>portal1[0]:b</div>');
expect(portalContainer2.innerHTML).toBe(
'<div>portal2[0]:b</div><div>portal2[1]:b</div>',
);
expect(container.innerHTML).toBe(
'<div>normal[0]:b</div><div>normal[1]:b</div>',
);
assertLog([
'normal[0]:b componentDidUpdate',
'portal1[0]:b componentDidUpdate',
'normal[1]:b componentDidUpdate',
'portal2[0]:b componentDidUpdate',
'portal2[1]:b componentDidUpdate',
'Parent:b componentDidUpdate',
]);
root.unmount();
expect(portalContainer1.innerHTML).toBe('');
expect(portalContainer2.innerHTML).toBe('');
expect(container.innerHTML).toBe('');
assertLog([
'Parent:b componentWillUnmount',
'normal[0]:b componentWillUnmount',
'portal1[0]:b componentWillUnmount',
'normal[1]:b componentWillUnmount',
'portal2[0]:b componentWillUnmount',
'portal2[1]:b componentWillUnmount',
]);
});
it('should render nested portals', async () => {
const portalContainer1 = document.createElement('div');
const portalContainer2 = document.createElement('div');
const portalContainer3 = document.createElement('div');
await act(() => {
root.render([
<div key="a">normal[0]</div>,
ReactDOM.createPortal(
[
<div key="b">portal1[0]</div>,
ReactDOM.createPortal(
<div key="c">portal2[0]</div>,
portalContainer2,
),
ReactDOM.createPortal(
<div key="d">portal3[0]</div>,
portalContainer3,
),
<div key="e">portal1[1]</div>,
],
portalContainer1,
),
<div key="f">normal[1]</div>,
]);
});
expect(portalContainer1.innerHTML).toBe(
'<div>portal1[0]</div><div>portal1[1]</div>',
);
expect(portalContainer2.innerHTML).toBe('<div>portal2[0]</div>');
expect(portalContainer3.innerHTML).toBe('<div>portal3[0]</div>');
expect(container.innerHTML).toBe(
'<div>normal[0]</div><div>normal[1]</div>',
);
root.unmount();
expect(portalContainer1.innerHTML).toBe('');
expect(portalContainer2.innerHTML).toBe('');
expect(portalContainer3.innerHTML).toBe('');
expect(container.innerHTML).toBe('');
});
it('should reconcile portal children', async () => {
const portalContainer = document.createElement('div');
await act(() => {
root.render(
<div>
{ReactDOM.createPortal(<div>portal:1</div>, portalContainer)}
</div>,
);
});
expect(portalContainer.innerHTML).toBe('<div>portal:1</div>');
expect(container.innerHTML).toBe('<div></div>');
await act(() => {
root.render(
<div>
{ReactDOM.createPortal(<div>portal:2</div>, portalContainer)}
</div>,
);
});
expect(portalContainer.innerHTML).toBe('<div>portal:2</div>');
expect(container.innerHTML).toBe('<div></div>');
await act(() => {
root.render(
<div>{ReactDOM.createPortal(<p>portal:3</p>, portalContainer)}</div>,
);
});
expect(portalContainer.innerHTML).toBe('<p>portal:3</p>');
expect(container.innerHTML).toBe('<div></div>');
await act(() => {
root.render(
<div>{ReactDOM.createPortal(['Hi', 'Bye'], portalContainer)}</div>,
);
});
expect(portalContainer.innerHTML).toBe('HiBye');
expect(container.innerHTML).toBe('<div></div>');
await act(() => {
root.render(
<div>{ReactDOM.createPortal(['Bye', 'Hi'], portalContainer)}</div>,
);
});
expect(portalContainer.innerHTML).toBe('ByeHi');
expect(container.innerHTML).toBe('<div></div>');
await act(() => {
root.render(<div>{ReactDOM.createPortal(null, portalContainer)}</div>);
});
expect(portalContainer.innerHTML).toBe('');
expect(container.innerHTML).toBe('<div></div>');
});
it('should unmount empty portal component wherever it appears', async () => {
const portalContainer = document.createElement('div');
let instance;
class Wrapper extends React.Component {
constructor(props) {
super(props);
instance = this;
this.state = {
show: true,
};
}
render() {
return (
<div>
{this.state.show && (
<>
{ReactDOM.createPortal(null, portalContainer)}
<div>child</div>
</>
)}
<div>parent</div>
</div>
);
}
}
await act(() => {
root.render(<Wrapper />);
});
expect(container.innerHTML).toBe(
'<div><div>child</div><div>parent</div></div>',
);
await act(() => {
instance.setState({show: false});
});
expect(instance.state.show).toBe(false);
expect(container.innerHTML).toBe('<div><div>parent</div></div>');
});
it('should keep track of namespace across portals (simple)', async () => {
await assertNamespacesMatch(
<svg {...expectSVG}>
<image {...expectSVG} />
{usePortal(<div {...expectHTML} />)}
<image {...expectSVG} />
</svg>,
);
await assertNamespacesMatch(
<math {...expectMath}>
<mi {...expectMath} />
{usePortal(<div {...expectHTML} />)}
<mi {...expectMath} />
</math>,
);
await assertNamespacesMatch(
<div {...expectHTML}>
<p {...expectHTML} />
{usePortal(
<svg {...expectSVG}>
<image {...expectSVG} />
</svg>,
)}
<p {...expectHTML} />
</div>,
);
});
it('should keep track of namespace across portals (medium)', async () => {
await assertNamespacesMatch(
<svg {...expectSVG}>
<image {...expectSVG} />
{usePortal(<div {...expectHTML} />)}
<image {...expectSVG} />
{usePortal(<div {...expectHTML} />)}
<image {...expectSVG} />
</svg>,
);
await assertNamespacesMatch(
<div {...expectHTML}>
<math {...expectMath}>
<mi {...expectMath} />
{usePortal(
<svg {...expectSVG}>
<image {...expectSVG} />
</svg>,
)}
</math>
<p {...expectHTML} />
</div>,
);
await assertNamespacesMatch(
<math {...expectMath}>
<mi {...expectMath} />
{usePortal(
<svg {...expectSVG}>
<image {...expectSVG} />
<foreignObject {...expectSVG}>
<p {...expectHTML} />
<math {...expectMath}>
<mi {...expectMath} />
</math>
<p {...expectHTML} />
</foreignObject>
<image {...expectSVG} />
</svg>,
)}
<mi {...expectMath} />
</math>,
);
await assertNamespacesMatch(
<div {...expectHTML}>
{usePortal(
<svg {...expectSVG}>
{usePortal(<div {...expectHTML} />)}
<image {...expectSVG} />
</svg>,
)}
<p {...expectHTML} />
</div>,
);
await assertNamespacesMatch(
<svg {...expectSVG}>
<svg {...expectSVG}>
{usePortal(<div {...expectHTML} />)}
<image {...expectSVG} />
</svg>
<image {...expectSVG} />
</svg>,
);
});
it('should keep track of namespace across portals (complex)', async () => {
await assertNamespacesMatch(
<div {...expectHTML}>
{usePortal(
<svg {...expectSVG}>
<image {...expectSVG} />
</svg>,
)}
<p {...expectHTML} />
<svg {...expectSVG}>
<image {...expectSVG} />
</svg>
<svg {...expectSVG}>
<svg {...expectSVG}>
<image {...expectSVG} />
</svg>
<image {...expectSVG} />
</svg>
<p {...expectHTML} />
</div>,
);
await assertNamespacesMatch(
<div {...expectHTML}>
<svg {...expectSVG}>
<svg {...expectSVG}>
<image {...expectSVG} />
{usePortal(
<svg {...expectSVG}>
<image {...expectSVG} />
<svg {...expectSVG}>
<image {...expectSVG} />
</svg>
<image {...expectSVG} />
</svg>,
)}
<image {...expectSVG} />
<foreignObject {...expectSVG}>
<p {...expectHTML} />
{usePortal(<p {...expectHTML} />)}
<p {...expectHTML} />
</foreignObject>
</svg>
<image {...expectSVG} />
</svg>
<p {...expectHTML} />
</div>,
);
await assertNamespacesMatch(
<div {...expectHTML}>
<svg {...expectSVG}>
<foreignObject {...expectSVG}>
<p {...expectHTML} />
{usePortal(
<svg {...expectSVG}>
<image {...expectSVG} />
<svg {...expectSVG}>
<image {...expectSVG} />
<foreignObject {...expectSVG}>
<p {...expectHTML} />
</foreignObject>
{usePortal(<p {...expectHTML} />)}
</svg>
<image {...expectSVG} />
</svg>,
)}
<p {...expectHTML} />
</foreignObject>
<image {...expectSVG} />
</svg>
<p {...expectHTML} />
</div>,
);
});
it('should unwind namespaces on uncaught errors', async () => {
function BrokenRender() {
throw new Error('Hello');
}
await expect(async () => {
await assertNamespacesMatch(
<svg {...expectSVG}>
<BrokenRender />
</svg>,
);
}).rejects.toThrow('Hello');
await assertNamespacesMatch(<div {...expectHTML} />);
});
it('should unwind namespaces on caught errors', async () => {
function BrokenRender() {
throw new Error('Hello');
}
class ErrorBoundary extends React.Component {
state = {error: null};
componentDidCatch(error) {
this.setState({error});
}
render() {
if (this.state.error) {
return <p {...expectHTML} />;
}
return this.props.children;
}
}
await assertNamespacesMatch(
<svg {...expectSVG}>
<foreignObject {...expectSVG}>
<ErrorBoundary>
<math {...expectMath}>
<BrokenRender />
</math>
</ErrorBoundary>
</foreignObject>
<image {...expectSVG} />
</svg>,
);
await assertNamespacesMatch(<div {...expectHTML} />);
});
it('should unwind namespaces on caught errors in a portal', async () => {
function BrokenRender() {
throw new Error('Hello');
}
class ErrorBoundary extends React.Component {
state = {error: null};
componentDidCatch(error) {
this.setState({error});
}
render() {
if (this.state.error) {
return <image {...expectSVG} />;
}
return this.props.children;
}
}
await assertNamespacesMatch(
<svg {...expectSVG}>
<ErrorBoundary>
{usePortal(
<div {...expectHTML}>
<math {...expectMath}>
<BrokenRender />)
</math>
</div>,
)}
</ErrorBoundary>
{usePortal(<div {...expectHTML} />)}
</svg>,
);
});
// @gate !disableLegacyContext
it('should pass portal context when rendering subtree elsewhere', async () => {
const portalContainer = document.createElement('div');
class Component extends React.Component {
static contextTypes = {
foo: PropTypes.string.isRequired,
};
render() {
return <div>{this.context.foo}</div>;
}
}
class Parent extends React.Component {
static childContextTypes = {
foo: PropTypes.string.isRequired,
};
getChildContext() {
return {
foo: 'bar',
};
}
render() {
return ReactDOM.createPortal(<Component />, portalContainer);
}
}
await act(async () => {
root.render(<Parent />);
});
expect(container.innerHTML).toBe('');
expect(portalContainer.innerHTML).toBe('<div>bar</div>');
});
it('should bubble events from the portal to the parent', async () => {
const portalContainer = document.createElement('div');
document.body.appendChild(portalContainer);
try {
let portal = null;
await act(() => {
root.render(
<div onClick={() => Scheduler.log('parent clicked')}>
{ReactDOM.createPortal(
<div
onClick={() => Scheduler.log('portal clicked')}
ref={n => (portal = n)}>
portal
</div>,
portalContainer,
)}
</div>,
);
});
expect(portal.tagName).toBe('DIV');
await act(() => {
portal.click();
});
assertLog(['portal clicked', 'parent clicked']);
} finally {
document.body.removeChild(portalContainer);
}
});
it('should not onMouseLeave when staying in the portal', async () => {
const portalContainer = document.createElement('div');
document.body.appendChild(portalContainer);
let firstTarget = null;
let secondTarget = null;
let thirdTarget = null;
function simulateMouseMove(from, to) {
if (from) {
from.dispatchEvent(
new MouseEvent('mouseout', {
bubbles: true,
cancelable: true,
relatedTarget: to,
}),
);
}
if (to) {
to.dispatchEvent(
new MouseEvent('mouseover', {
bubbles: true,
cancelable: true,
relatedTarget: from,
}),
);
}
}
try {
await act(() => {
root.render(
<div>
<div
onMouseEnter={() => Scheduler.log('enter parent')}
onMouseLeave={() => Scheduler.log('leave parent')}>
<div ref={n => (firstTarget = n)} />
{ReactDOM.createPortal(
<div
onMouseEnter={() => Scheduler.log('enter portal')}
onMouseLeave={() => Scheduler.log('leave portal')}
ref={n => (secondTarget = n)}>
portal
</div>,
portalContainer,
)}
</div>
<div ref={n => (thirdTarget = n)} />
</div>,
);
});
await act(() => {
simulateMouseMove(null, firstTarget);
});
assertLog(['enter parent']);
await act(() => {
simulateMouseMove(firstTarget, secondTarget);
});
assertLog([
// Parent did not invoke leave because we're still inside the portal.
'enter portal',
]);
await act(() => {
simulateMouseMove(secondTarget, thirdTarget);
});
assertLog([
'leave portal',
'leave parent', // Only when we leave the portal does onMouseLeave fire.
]);
} finally {
document.body.removeChild(portalContainer);
}
});
// Regression test for https://github.com/facebook/react/issues/19562
it('does not fire mouseEnter twice when relatedTarget is the root node', async () => {
let target = null;
function simulateMouseMove(from, to) {
if (from) {
from.dispatchEvent(
new MouseEvent('mouseout', {
bubbles: true,
cancelable: true,
relatedTarget: to,
}),
);
}
if (to) {
to.dispatchEvent(
new MouseEvent('mouseover', {
bubbles: true,
cancelable: true,
relatedTarget: from,
}),
);
}
}
await act(() => {
root.render(
<div
ref={n => (target = n)}
onMouseEnter={() => Scheduler.log('enter')}
onMouseLeave={() => Scheduler.log('leave')}
/>,
);
});
await act(() => {
simulateMouseMove(null, container);
});
assertLog([]);
await act(() => {
simulateMouseMove(container, target);
});
assertLog(['enter']);
await act(() => {
simulateMouseMove(target, container);
});
assertLog(['leave']);
await act(() => {
simulateMouseMove(container, null);
});
assertLog([]);
});
it('listens to events that do not exist in the Portal subtree', async () => {
const onClick = jest.fn();
const ref = React.createRef();
await act(() => {
root.render(
<div onClick={onClick}>
{ReactDOM.createPortal(
<button ref={ref}>click</button>,
document.body,
)}
</div>,
);
});
const event = new MouseEvent('click', {
bubbles: true,
});
await act(() => {
ref.current.dispatchEvent(event);
});
expect(onClick).toHaveBeenCalledTimes(1);
});
it('should throw on bad createPortal argument', () => {
expect(() => {
ReactDOM.createPortal(<div>portal</div>, null);
}).toThrow('Target container is not a DOM element.');
expect(() => {
ReactDOM.createPortal(<div>portal</div>, document.createTextNode('hi'));
}).toThrow('Target container is not a DOM element.');
});
it('should warn for non-functional event listeners', () => {
class Example extends React.Component {
render() {
return <div onClick="woops" />;
}
}
expect(() => {
ReactDOM.flushSync(() => {
root.render(<Example />);
});
}).toErrorDev(
'Expected `onClick` listener to be a function, instead got a value of `string` type.\n' +
' in div (at **)\n' +
' in Example (at **)',
);
});
it('should warn with a special message for `false` event listeners', () => {
class Example extends React.Component {
render() {
return <div onClick={false} />;
}
}
expect(() => {
ReactDOM.flushSync(() => {
root.render(<Example />);
});
}).toErrorDev(
'Expected `onClick` listener to be a function, instead got `false`.\n\n' +
'If you used to conditionally omit it with onClick={condition && value}, ' +
'pass onClick={condition ? value : undefined} instead.\n' +
' in div (at **)\n' +
' in Example (at **)',
);
});
it('should not update event handlers until commit', async () => {
const handlerA = () => Scheduler.log('A');
const handlerB = () => Scheduler.log('B');
function click() {
const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
});
Object.defineProperty(event, 'timeStamp', {
value: 0,
});
node.dispatchEvent(event);
}
class Example extends React.Component {
state = {flip: false, count: 0};
flip() {
this.setState({flip: true, count: this.state.count + 1});
}
tick() {
this.setState({count: this.state.count + 1});
}
render() {
const useB = !this.props.forceA && this.state.flip;
return <div onClick={useB ? handlerB : handlerA} />;
}
}
class Click extends React.Component {
constructor() {
super();
node.click();
}
render() {
return null;
}
}
let inst;
await act(() => {
root.render([<Example key="a" ref={n => (inst = n)} />]);
});
const node = container.firstChild;
expect(node.tagName).toEqual('DIV');
await act(() => {
click();
});
assertLog(['A']);
// Render with the other event handler.
await act(() => {
inst.flip();
});
await act(() => {
click();
});
assertLog(['B']);
// Rerender without changing any props.
await act(() => {
inst.tick();
});
await act(() => {
click();
});
assertLog(['B']);
// Render a flip back to the A handler. The second component invokes the
// click handler during render to simulate a click during an aborted
// render. I use this hack because at current time we don't have a way to
// test aborted ReactDOM renders.
await act(() => {
root.render([<Example key="a" forceA={true} />, <Click key="b" />]);
});
// Because the new click handler has not yet committed, we should still
// invoke B.
assertLog(['B']);
// Any click that happens after commit, should invoke A.
await act(() => {
click();
});
assertLog(['A']);
});
it('should not crash encountering low-priority tree', async () => {
await act(() => {
root.render(
<div hidden={true}>
<div />
</div>,
);
});
expect(container.innerHTML).toBe('<div hidden=""><div></div></div>');
});
it('should not warn when rendering into an empty container', async () => {
await act(() => {
root.render(<div>foo</div>);
});
expect(container.innerHTML).toBe('<div>foo</div>');
await act(() => {
root.render(null);
});
expect(container.innerHTML).toBe('');
await act(() => {
root.render(<div>bar</div>);
});
expect(container.innerHTML).toBe('<div>bar</div>');
});
it('should warn when replacing a container which was manually updated outside of React', async () => {
// when not messing with the DOM outside of React
await act(() => {
root.render(<div key="1">foo</div>);
});
expect(container.innerHTML).toBe('<div>foo</div>');
await act(() => {
root.render(<div key="1">bar</div>);
});
expect(container.innerHTML).toBe('<div>bar</div>');
// then we mess with the DOM before an update
// we know this will error - that is expected right now
// It's an error of type 'NotFoundError' with no message
container.innerHTML = '<div>MEOW.</div>';
await expect(async () => {
await act(() => {
ReactDOM.flushSync(() => {
root.render(<div key="2">baz</div>);
});
});
}).rejects.toThrow('The node to be removed is not a child of this node');
});
it('should not warn when doing an update to a container manually updated outside of React', async () => {
// when not messing with the DOM outside of React
await act(() => {
root.render(<div>foo</div>);
});
expect(container.innerHTML).toBe('<div>foo</div>');
await act(() => {
root.render(<div>bar</div>);
});
expect(container.innerHTML).toBe('<div>bar</div>');
// then we mess with the DOM before an update
container.innerHTML = '<div>MEOW.</div>';
await act(() => {
root.render(<div>baz</div>);
});
// TODO: why not, and no error?
expect(container.innerHTML).toBe('<div>MEOW.</div>');
});
it('should not warn when doing an update to a container manually cleared outside of React', async () => {
// when not messing with the DOM outside of React
await act(() => {
root.render(<div>foo</div>);
});
expect(container.innerHTML).toBe('<div>foo</div>');
await act(() => {
root.render(<div>bar</div>);
});
expect(container.innerHTML).toBe('<div>bar</div>');
// then we mess with the DOM before an update
container.innerHTML = '';
await act(() => {
root.render(<div>baz</div>);
});
// TODO: why not, and no error?
expect(container.innerHTML).toBe('');
});
it('should render a text component with a text DOM node on the same document as the container', async () => {
// 1. Create a new document through the use of iframe
// 2. Set up the spy to make asserts when a text component
// is rendered inside the iframe container
const textContent = 'Hello world';
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
const iframeDocument = iframe.contentDocument;
iframeDocument.write(
'<!DOCTYPE html><html><head></head><body><div></div></body></html>',
);
iframeDocument.close();
const iframeContainer = iframeDocument.body.firstChild;
let actualDocument;
let textNode;
spyOnDevAndProd(iframeContainer, 'appendChild').mockImplementation(node => {
actualDocument = node.ownerDocument;
textNode = node;
});
const iFrameRoot = ReactDOMClient.createRoot(iframeContainer);
await act(() => {
iFrameRoot.render(textContent);
});
expect(textNode.textContent).toBe(textContent);
expect(actualDocument).not.toBe(document);
expect(actualDocument).toBe(iframeDocument);
expect(iframeContainer.appendChild).toHaveBeenCalledTimes(1);
});
it('should mount into a document fragment', async () => {
const fragment = document.createDocumentFragment();
const fragmentRoot = ReactDOMClient.createRoot(fragment);
await act(() => {
fragmentRoot.render(<div>foo</div>);
});
expect(container.innerHTML).toBe('');
container.appendChild(fragment);
expect(container.innerHTML).toBe('<div>foo</div>');
});
// Regression test for https://github.com/facebook/react/issues/12643#issuecomment-413727104
it('should not diff memoized host components', async () => {
const inputRef = React.createRef();
let didCallOnChange = false;
class Child extends React.Component {
state = {};
componentDidMount() {
document.addEventListener('click', this.update, true);
}
componentWillUnmount() {
document.removeEventListener('click', this.update, true);
}
update = () => {
// We're testing that this setState()
// doesn't cause React to commit updates
// to the input outside (which would itself
// prevent the parent's onChange parent handler
// from firing).
this.setState({});
// Note that onChange was always broken when there was an
// earlier setState() in a manual document capture phase
// listener *in the same component*. But that's very rare.
// Here we're testing that a *child* component doesn't break
// the parent if this happens.
};
render() {
return <div />;
}
}
class Parent extends React.Component {
handleChange = val => {
didCallOnChange = true;
};
render() {
return (
<div>
<Child />
<input
ref={inputRef}
type="checkbox"
checked={true}
onChange={this.handleChange}
/>
</div>
);
}
}
await act(() => {
root.render(<Parent />);
});
await act(() => {
inputRef.current.dispatchEvent(
new MouseEvent('click', {
bubbles: true,
}),
);
});
expect(didCallOnChange).toBe(true);
});
});