Files
react/packages/react-dom/src/__tests__/ReactDOMConsoleErrorReportingLegacy-test.js
Sebastian Markbåge 400e822277 Remove Component Stack from React Logged Warnings and Error Reporting (#30308)
React transpiles some of its own `console.error` calls into a helper
that appends component stacks to those calls. However, this doesn't
cover user space `console.error` calls - which includes React helpers
that React has moved into third parties like createClass and prop-types.

The idea is that any user space component can add a warning just like
React can which is why React DevTools adds them too if they don't
already exist. Having them appended in both places is tricky because now
you have to know whether to remove them from React's logs.

Similarly it's often common for server-side frameworks to forget to
cover the `console.error` logs from other sources since React DevTools
isn't active there. However, it's also annoying to get component stacks
clogging the terminal - depending on where the log came from.

In the future `console.createTask()` will cover this use case natively
and when available we don't append them at all.

The new strategy relies on either:

- React DevTools existing to add them to React logs as well as third
parties.
- `console.createTask` being supported and surfaced.
- A third party framework showing the component stack either in an Error
Dialog or appended to terminal output.

For a third party to be able to implement this they need to be able to
get the component stack. To get the component stack from within a
`console.error` call you need to use the `React.captureOwnerStack()`
helper which is only available in `enableOwnerStacks` flag. However,
it's possible to polyfill with parent stacks using internals as a stop
gap. There's a question of whether React 19 should just go out with
`enableOwnerStacks` to expose this but regardless I think it's best it
doesn't include component stacks from the runtime for consistency.

In practice it's not really a regression though because typically either
of the other options exists and error dialogs don't implement
`console.error` overrides anyway yet. SSR terminals might miss them but
they'd only have them in DEV warnings to begin with an a subset of React
warnings. Typically those are either going to happen on the client
anyway or replayed.

Our tests are written to assert that component stacks work in various
scenarios all over the place. To ensure that this keeps working I
implement a "polyfill" that is similar to that expected a server
framework might do - in `assertConsoleErrorDev` and `toErrorDev`.

This PR doesn't yet change www or RN since they have their own forks of
consoleWithStackDev for now.
2024-07-12 13:02:22 -04:00

587 lines
16 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.
*/
'use strict';
describe('ReactDOMConsoleErrorReporting', () => {
let act;
let React;
let ReactDOM;
let ErrorBoundary;
let NoError;
let container;
let windowOnError;
let waitForThrow;
beforeEach(() => {
jest.resetModules();
act = require('internal-test-utils').act;
React = require('react');
ReactDOM = require('react-dom');
const InternalTestUtils = require('internal-test-utils');
waitForThrow = InternalTestUtils.waitForThrow;
ErrorBoundary = class extends React.Component {
state = {error: null};
static getDerivedStateFromError(error) {
return {error};
}
render() {
if (this.state.error) {
return <h1>Caught: {this.state.error.message}</h1>;
}
return this.props.children;
}
};
NoError = function () {
return <h1>OK</h1>;
};
container = document.createElement('div');
document.body.appendChild(container);
windowOnError = jest.fn();
window.addEventListener('error', windowOnError);
spyOnDevAndProd(console, 'error');
spyOnDevAndProd(console, 'warn');
});
afterEach(() => {
document.body.removeChild(container);
window.removeEventListener('error', windowOnError);
jest.restoreAllMocks();
});
describe('ReactDOM.render', () => {
// @gate !disableLegacyMode
it('logs errors during event handlers', async () => {
function Foo() {
return (
<button
onClick={() => {
throw Error('Boom');
}}>
click me
</button>
);
}
await act(() => {
ReactDOM.render(<Foo />, container);
});
await expect(async () => {
await act(() => {
container.firstChild.dispatchEvent(
new MouseEvent('click', {
bubbles: true,
}),
);
});
}).rejects.toThrow(
expect.objectContaining({
message: 'Boom',
}),
);
// Reported because we're in a browser click event:
expect(windowOnError.mock.calls).toEqual([
[
expect.objectContaining({
message: 'Boom',
}),
],
]);
expect(console.warn).not.toBeCalled();
if (__DEV__) {
expect(console.error.mock.calls).toEqual([
[
expect.stringContaining(
'ReactDOM.render has not been supported since React 18',
),
],
]);
} else {
expect(console.error).not.toBeCalled();
}
// Check next render doesn't throw.
windowOnError.mockReset();
console.warn.mockReset();
console.error.mockReset();
await act(() => {
ReactDOM.render(<NoError />, container);
});
expect(container.textContent).toBe('OK');
expect(windowOnError).not.toBeCalled();
expect(console.warn).not.toBeCalled();
if (__DEV__) {
expect(console.error.mock.calls).toEqual([
[
expect.stringContaining(
'ReactDOM.render has not been supported since React 18',
),
],
]);
} else {
expect(console.error).not.toBeCalled();
}
});
// @gate !disableLegacyMode
it('logs render errors without an error boundary', async () => {
function Foo() {
throw Error('Boom');
}
await expect(async () => {
await act(() => {
ReactDOM.render(<Foo />, container);
});
}).rejects.toThrow('Boom');
// Reported because errors without a boundary are reported to window.
expect(windowOnError.mock.calls).toEqual([
[
expect.objectContaining({
message: 'Boom',
}),
],
]);
if (__DEV__) {
expect(console.warn.mock.calls).toEqual([
[
// Formatting
expect.stringContaining('%s'),
// Addendum by React:
expect.stringContaining('An error occurred in the <Foo> component'),
expect.stringContaining('Consider adding an error boundary'),
// The component stack is not added without the polyfill/devtools.
// expect.stringContaining('Foo'),
],
]);
expect(console.error.mock.calls).toEqual([
[
expect.stringContaining(
'ReactDOM.render has not been supported since React 18',
),
],
]);
} else {
expect(console.warn).not.toBeCalled();
expect(console.error).not.toBeCalled();
}
// Check next render doesn't throw.
windowOnError.mockReset();
console.warn.mockReset();
console.error.mockReset();
await act(() => {
ReactDOM.render(<NoError />, container);
});
expect(container.textContent).toBe('OK');
expect(console.warn).not.toBeCalled();
expect(windowOnError).not.toBeCalled();
if (__DEV__) {
expect(console.error.mock.calls).toEqual([
[
expect.stringContaining(
'ReactDOM.render has not been supported since React 18',
),
],
]);
} else {
expect(console.error).not.toBeCalled();
}
});
// @gate !disableLegacyMode
it('logs render errors with an error boundary', async () => {
function Foo() {
throw Error('Boom');
}
await act(() => {
ReactDOM.render(
<ErrorBoundary>
<Foo />
</ErrorBoundary>,
container,
);
});
// The top-level error was caught with try/catch,
// so we don't see an error event.
expect(windowOnError).not.toBeCalled();
expect(console.warn).not.toBeCalled();
if (__DEV__) {
expect(console.error.mock.calls).toEqual([
[
expect.stringContaining(
'ReactDOM.render has not been supported since React 18',
),
],
[
// Formatting
expect.stringContaining('%o'),
expect.objectContaining({
message: 'Boom',
}),
// Addendum by React:
expect.stringContaining(
'The above error occurred in the <Foo> component',
),
expect.stringContaining('ErrorBoundary'),
// The component stack is not added without the polyfill/devtools.
// expect.stringContaining('Foo'),
],
]);
} else {
expect(console.error.mock.calls).toEqual([
[
// Reported by React with no extra message:
expect.objectContaining({
message: 'Boom',
}),
],
]);
}
// Check next render doesn't throw.
windowOnError.mockReset();
console.error.mockReset();
console.warn.mockReset();
await act(() => {
ReactDOM.render(<NoError />, container);
});
expect(container.textContent).toBe('OK');
expect(windowOnError).not.toBeCalled();
expect(console.warn).not.toBeCalled();
if (__DEV__) {
expect(console.error.mock.calls).toEqual([
[
expect.stringContaining(
'ReactDOM.render has not been supported since React 18',
),
],
]);
} else {
expect(console.error).not.toBeCalled();
}
});
// @gate !disableLegacyMode
it('logs layout effect errors without an error boundary', async () => {
function Foo() {
React.useLayoutEffect(() => {
throw Error('Boom');
}, []);
return null;
}
await expect(async () => {
await act(() => {
ReactDOM.render(<Foo />, container);
});
}).rejects.toThrow('Boom');
// Reported because errors without a boundary are reported to window.
expect(windowOnError.mock.calls).toEqual([
[
expect.objectContaining({
message: 'Boom',
}),
],
]);
if (__DEV__) {
expect(console.warn.mock.calls).toEqual([
[
// Formatting
expect.stringContaining('%s'),
// Addendum by React:
expect.stringContaining('An error occurred in the <Foo> component'),
expect.stringContaining('Consider adding an error boundary'),
// The component stack is not added without the polyfill/devtools.
// expect.stringContaining('Foo'),
],
]);
expect(console.error.mock.calls).toEqual([
[
expect.stringContaining(
'ReactDOM.render has not been supported since React 18',
),
],
]);
} else {
expect(console.warn).not.toBeCalled();
expect(console.error).not.toBeCalled();
}
// Check next render doesn't throw.
windowOnError.mockReset();
console.warn.mockReset();
console.error.mockReset();
await act(() => {
ReactDOM.render(<NoError />, container);
});
expect(container.textContent).toBe('OK');
expect(console.warn).not.toBeCalled();
expect(windowOnError).not.toBeCalled();
if (__DEV__) {
expect(console.error.mock.calls).toEqual([
[
expect.stringContaining(
'ReactDOM.render has not been supported since React 18',
),
],
]);
} else {
expect(console.error).not.toBeCalled();
}
});
// @gate !disableLegacyMode
it('logs layout effect errors with an error boundary', async () => {
function Foo() {
React.useLayoutEffect(() => {
throw Error('Boom');
}, []);
return null;
}
await act(() => {
ReactDOM.render(
<ErrorBoundary>
<Foo />
</ErrorBoundary>,
container,
);
});
// The top-level error was caught with try/catch,
// so we don't see an error event.
expect(windowOnError).not.toBeCalled();
expect(console.warn).not.toBeCalled();
if (__DEV__) {
expect(console.error.mock.calls).toEqual([
[
expect.stringContaining(
'ReactDOM.render has not been supported since React 18',
),
],
[
// Formatting
expect.stringContaining('%o'),
expect.objectContaining({
message: 'Boom',
}),
// Addendum by React:
expect.stringContaining(
'The above error occurred in the <Foo> component',
),
expect.stringContaining('ErrorBoundary'),
// The component stack is not added without the polyfill/devtools.
// expect.stringContaining('Foo'),
],
]);
} else {
expect(console.error.mock.calls).toEqual([
[
// Reported by React with no extra message:
expect.objectContaining({
message: 'Boom',
}),
],
]);
}
// Check next render doesn't throw.
windowOnError.mockReset();
console.warn.mockReset();
console.error.mockReset();
await act(() => {
ReactDOM.render(<NoError />, container);
});
expect(container.textContent).toBe('OK');
expect(windowOnError).not.toBeCalled();
expect(console.warn).not.toBeCalled();
if (__DEV__) {
expect(console.error.mock.calls).toEqual([
[
expect.stringContaining(
'ReactDOM.render has not been supported since React 18',
),
],
]);
} else {
expect(console.error).not.toBeCalled();
}
});
// @gate !disableLegacyMode
it('logs passive effect errors without an error boundary', async () => {
function Foo() {
React.useEffect(() => {
throw Error('Boom');
}, []);
return null;
}
await act(async () => {
ReactDOM.render(<Foo />, container);
await waitForThrow('Boom');
});
// The top-level error was caught with try/catch,
// so we don't see an error event.
expect(windowOnError.mock.calls).toEqual([
[
expect.objectContaining({
message: 'Boom',
}),
],
]);
if (__DEV__) {
expect(console.warn.mock.calls).toEqual([
[
// Formatting
expect.stringContaining('%s'),
// Addendum by React:
expect.stringContaining('An error occurred in the <Foo> component'),
expect.stringContaining('Consider adding an error boundary'),
// The component stack is not added without the polyfill/devtools.
// expect.stringContaining('Foo'),
],
]);
expect(console.error.mock.calls).toEqual([
[
expect.stringContaining(
'ReactDOM.render has not been supported since React 18',
),
],
]);
} else {
expect(console.warn).not.toBeCalled();
expect(console.error).not.toBeCalled();
}
// Check next render doesn't throw.
windowOnError.mockReset();
console.warn.mockReset();
console.error.mockReset();
await act(() => {
ReactDOM.render(<NoError />, container);
});
expect(container.textContent).toBe('OK');
expect(windowOnError).not.toBeCalled();
expect(console.warn).not.toBeCalled();
if (__DEV__) {
expect(console.error.mock.calls).toEqual([
[
expect.stringContaining(
'ReactDOM.render has not been supported since React 18',
),
],
]);
} else {
expect(console.error).not.toBeCalled();
}
});
// @gate !disableLegacyMode
it('logs passive effect errors with an error boundary', async () => {
function Foo() {
React.useEffect(() => {
throw Error('Boom');
}, []);
return null;
}
await act(() => {
ReactDOM.render(
<ErrorBoundary>
<Foo />
</ErrorBoundary>,
container,
);
});
// The top-level error was caught with try/catch,
// so we don't see an error event.
expect(windowOnError).not.toBeCalled();
expect(console.warn).not.toBeCalled();
if (__DEV__) {
expect(console.error.mock.calls).toEqual([
[
expect.stringContaining(
'ReactDOM.render has not been supported since React 18',
),
],
[
// Formatting
expect.stringContaining('%o'),
expect.objectContaining({
message: 'Boom',
}),
// Addendum by React:
expect.stringContaining(
'The above error occurred in the <Foo> component',
),
expect.stringContaining('ErrorBoundary'),
// The component stack is not added without the polyfill/devtools.
// expect.stringContaining('Foo'),
],
]);
} else {
expect(console.error.mock.calls).toEqual([
[
// Reported by React with no extra message:
expect.objectContaining({
message: 'Boom',
}),
],
]);
}
// Check next render doesn't throw.
windowOnError.mockReset();
console.warn.mockReset();
console.error.mockReset();
await act(() => {
ReactDOM.render(<NoError />, container);
});
expect(container.textContent).toBe('OK');
expect(windowOnError).not.toBeCalled();
expect(console.warn).not.toBeCalled();
if (__DEV__) {
expect(console.error.mock.calls).toEqual([
[
expect.stringContaining(
'ReactDOM.render has not been supported since React 18',
),
],
]);
} else {
expect(console.warn).not.toBeCalled();
}
});
});
});