[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>
This commit is contained in:
Sebastian Markbåge 2024-03-26 20:44:07 -07:00 committed by GitHub
parent 5910eb3456
commit 6786563f3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 1504 additions and 1046 deletions

View File

@ -13,6 +13,8 @@ import simulateBrowserEventDispatch from './simulateBrowserEventDispatch';
export {act} from './internalAct';
import {thrownErrors, actingUpdatesScopeDepth} from './internalAct';
function assertYieldsWereCleared(caller) {
const actualYields = SchedulerMock.unstable_clearLog();
if (actualYields.length !== 0) {
@ -110,6 +112,14 @@ ${diff(expectedLog, actualLog)}
throw error;
}
function aggregateErrors(errors: Array<mixed>): mixed {
if (errors.length > 1 && typeof AggregateError === 'function') {
// eslint-disable-next-line no-undef
return new AggregateError(errors);
}
return errors[0];
}
export async function waitForThrow(expectedError: mixed): mixed {
assertYieldsWereCleared(waitForThrow);
@ -126,31 +136,72 @@ export async function waitForThrow(expectedError: mixed): mixed {
error.message = 'Expected something to throw, but nothing did.';
throw error;
}
const errorHandlerDOM = function (event: ErrorEvent) {
// Prevent logs from reprinting this error.
event.preventDefault();
thrownErrors.push(event.error);
};
const errorHandlerNode = function (err: mixed) {
thrownErrors.push(err);
};
// We track errors that were logged globally as if they occurred in this scope and then rethrow them.
if (actingUpdatesScopeDepth === 0) {
if (
typeof window === 'object' &&
typeof window.addEventListener === 'function'
) {
// We're in a JS DOM environment.
window.addEventListener('error', errorHandlerDOM);
} else if (typeof process === 'object') {
// Node environment
process.on('uncaughtException', errorHandlerNode);
}
}
try {
SchedulerMock.unstable_flushAllWithoutAsserting();
} catch (x) {
thrownErrors.push(x);
} finally {
if (actingUpdatesScopeDepth === 0) {
if (
typeof window === 'object' &&
typeof window.addEventListener === 'function'
) {
// We're in a JS DOM environment.
window.removeEventListener('error', errorHandlerDOM);
} else if (typeof process === 'object') {
// Node environment
process.off('uncaughtException', errorHandlerNode);
}
}
}
if (thrownErrors.length > 0) {
const thrownError = aggregateErrors(thrownErrors);
thrownErrors.length = 0;
if (expectedError === undefined) {
// If no expected error was provided, then assume the caller is OK with
// any error being thrown. We're returning the error so they can do
// their own checks, if they wish.
return x;
return thrownError;
}
if (equals(x, expectedError)) {
return x;
if (equals(thrownError, expectedError)) {
return thrownError;
}
if (
typeof expectedError === 'string' &&
typeof x === 'object' &&
x !== null &&
typeof x.message === 'string'
typeof thrownError === 'object' &&
thrownError !== null &&
typeof thrownError.message === 'string'
) {
if (x.message.includes(expectedError)) {
return x;
if (thrownError.message.includes(expectedError)) {
return thrownError;
} else {
error.message = `
Expected error was not thrown.
${diff(expectedError, x.message)}
${diff(expectedError, thrownError.message)}
`;
throw error;
}
@ -158,7 +209,7 @@ ${diff(expectedError, x.message)}
error.message = `
Expected error was not thrown.
${diff(expectedError, x)}
${diff(expectedError, thrownError)}
`;
throw error;
}

View File

@ -20,7 +20,9 @@ import * as Scheduler from 'scheduler/unstable_mock';
import enqueueTask from './enqueueTask';
let actingUpdatesScopeDepth: number = 0;
export let actingUpdatesScopeDepth: number = 0;
export const thrownErrors: Array<mixed> = [];
async function waitForMicrotasks() {
return new Promise(resolve => {
@ -28,6 +30,14 @@ async function waitForMicrotasks() {
});
}
function aggregateErrors(errors: Array<mixed>): mixed {
if (errors.length > 1 && typeof AggregateError === 'function') {
// eslint-disable-next-line no-undef
return new AggregateError(errors);
}
return errors[0];
}
export async function act<T>(scope: () => Thenable<T>): Thenable<T> {
if (Scheduler.unstable_flushUntilNextPaint === undefined) {
throw Error(
@ -63,6 +73,28 @@ export async function act<T>(scope: () => Thenable<T>): Thenable<T> {
// public version of `act`, though we maybe should in the future.
await waitForMicrotasks();
const errorHandlerDOM = function (event: ErrorEvent) {
// Prevent logs from reprinting this error.
event.preventDefault();
thrownErrors.push(event.error);
};
const errorHandlerNode = function (err: mixed) {
thrownErrors.push(err);
};
// We track errors that were logged globally as if they occurred in this scope and then rethrow them.
if (actingUpdatesScopeDepth === 1) {
if (
typeof window === 'object' &&
typeof window.addEventListener === 'function'
) {
// We're in a JS DOM environment.
window.addEventListener('error', errorHandlerDOM);
} else if (typeof process === 'object') {
// Node environment
process.on('uncaughtException', errorHandlerNode);
}
}
try {
const result = await scope();
@ -106,10 +138,27 @@ export async function act<T>(scope: () => Thenable<T>): Thenable<T> {
Scheduler.unstable_flushUntilNextPaint();
} while (true);
if (thrownErrors.length > 0) {
// Rethrow any errors logged by the global error handling.
const thrownError = aggregateErrors(thrownErrors);
thrownErrors.length = 0;
throw thrownError;
}
return result;
} finally {
const depth = actingUpdatesScopeDepth;
if (depth === 1) {
if (
typeof window === 'object' &&
typeof window.addEventListener === 'function'
) {
// We're in a JS DOM environment.
window.removeEventListener('error', errorHandlerDOM);
} else if (typeof process === 'object') {
// Node environment
process.off('uncaughtException', errorHandlerNode);
}
global.IS_REACT_ACT_ENVIRONMENT = previousIsActEnvironment;
}
actingUpdatesScopeDepth = depth - 1;

View File

@ -68,6 +68,8 @@ import * as SelectEventPlugin from './plugins/SelectEventPlugin';
import * as SimpleEventPlugin from './plugins/SimpleEventPlugin';
import * as FormActionEventPlugin from './plugins/FormActionEventPlugin';
import reportGlobalError from 'shared/reportGlobalError';
type DispatchListener = {
instance: null | Fiber,
listener: Function,
@ -226,9 +228,6 @@ export const nonDelegatedEvents: Set<DOMEventName> = new Set([
...mediaEventTypes,
]);
let hasError: boolean = false;
let caughtError: mixed = null;
function executeDispatch(
event: ReactSyntheticEvent,
listener: Function,
@ -238,12 +237,7 @@ function executeDispatch(
try {
listener(event);
} catch (error) {
if (!hasError) {
hasError = true;
caughtError = error;
} else {
// TODO: Make sure this error gets logged somehow.
}
reportGlobalError(error);
}
event.currentTarget = null;
}
@ -285,13 +279,6 @@ export function processDispatchQueue(
processDispatchQueueItemsInOrder(event, listeners, inCapturePhase);
// event system doesn't use pooling.
}
// This would be a good time to rethrow if any of the event handlers threw.
if (hasError) {
const error = caughtError;
hasError = false;
caughtError = null;
throw error;
}
}
function dispatchEventsForPlugins(

View File

@ -51,13 +51,11 @@ describe('InvalidEventListeners', () => {
}
window.addEventListener('error', handleWindowError);
try {
await act(() => {
node.dispatchEvent(
new MouseEvent('click', {
bubbles: true,
}),
);
});
node.dispatchEvent(
new MouseEvent('click', {
bubbles: true,
}),
);
} finally {
window.removeEventListener('error', handleWindowError);
}

View File

@ -195,9 +195,7 @@ describe('ReactBrowserEventEmitter', () => {
});
window.addEventListener('error', errorHandler);
try {
await act(() => {
CHILD.click();
});
CHILD.click();
expect(idCallOrder.length).toBe(3);
expect(idCallOrder[0]).toBe(CHILD);
expect(idCallOrder[1]).toBe(PARENT);

View File

@ -223,12 +223,12 @@ describe('ReactCompositeComponent', () => {
const el = document.createElement('div');
const root = ReactDOMClient.createRoot(el);
expect(() => {
expect(() => {
ReactDOM.flushSync(() => {
await expect(async () => {
await expect(async () => {
await act(() => {
root.render(<Child test="test" />);
});
}).toThrow(
}).rejects.toThrow(
'Objects are not valid as a React child (found: object with keys {render}).',
);
}).toErrorDev(
@ -526,12 +526,12 @@ describe('ReactCompositeComponent', () => {
}
}
const root = ReactDOMClient.createRoot(container);
expect(() => {
expect(() => {
ReactDOM.flushSync(() => {
await expect(async () => {
await expect(async () => {
await act(() => {
root.render(<ClassWithRenderNotExtended />);
});
}).toThrow(TypeError);
}).rejects.toThrow(TypeError);
}).toErrorDev(
'Warning: The <ClassWithRenderNotExtended /> component appears to have a render method, ' +
"but doesn't extend React.Component. This is likely to cause errors. " +
@ -539,11 +539,11 @@ describe('ReactCompositeComponent', () => {
);
// Test deduplication
expect(() => {
ReactDOM.flushSync(() => {
await expect(async () => {
await act(() => {
root.render(<ClassWithRenderNotExtended />);
});
}).toThrow(TypeError);
}).rejects.toThrow(TypeError);
});
it('should warn about `setState` in render', async () => {
@ -596,11 +596,11 @@ describe('ReactCompositeComponent', () => {
expect(ReactCurrentOwner.current).toBe(null);
const root = ReactDOMClient.createRoot(document.createElement('div'));
expect(() => {
ReactDOM.flushSync(() => {
await expect(async () => {
await act(() => {
root.render(instance);
});
}).toThrow();
}).rejects.toThrow();
expect(ReactCurrentOwner.current).toBe(null);
});
@ -884,7 +884,7 @@ describe('ReactCompositeComponent', () => {
);
});
it('should only call componentWillUnmount once', () => {
it('should only call componentWillUnmount once', async () => {
let app;
let count = 0;
@ -919,14 +919,14 @@ describe('ReactCompositeComponent', () => {
};
const root = ReactDOMClient.createRoot(container);
expect(() => {
ReactDOM.flushSync(() => {
await expect(async () => {
await act(() => {
root.render(<App ref={setRef} stage={1} />);
});
ReactDOM.flushSync(() => {
await act(() => {
root.render(<App ref={setRef} stage={2} />);
});
}).toThrow();
}).rejects.toThrow();
expect(count).toBe(1);
});
@ -1211,7 +1211,7 @@ describe('ReactCompositeComponent', () => {
assertLog(['setState callback called']);
});
it('should return a meaningful warning when constructor is returned', () => {
it('should return a meaningful warning when constructor is returned', async () => {
class RenderTextInvalidConstructor extends React.Component {
constructor(props) {
super(props);
@ -1224,12 +1224,12 @@ describe('ReactCompositeComponent', () => {
}
const root = ReactDOMClient.createRoot(document.createElement('div'));
expect(() => {
expect(() => {
ReactDOM.flushSync(() => {
await expect(async () => {
await expect(async () => {
await act(() => {
root.render(<RenderTextInvalidConstructor />);
});
}).toThrow();
}).rejects.toThrow();
}).toErrorDev([
'Warning: No `render` method found on the RenderTextInvalidConstructor instance: ' +
'did you accidentally return an object from the constructor?',
@ -1260,16 +1260,16 @@ describe('ReactCompositeComponent', () => {
);
});
it('should return error if render is not defined', () => {
it('should return error if render is not defined', async () => {
class RenderTestUndefinedRender extends React.Component {}
const root = ReactDOMClient.createRoot(document.createElement('div'));
expect(() => {
expect(() => {
ReactDOM.flushSync(() => {
await expect(async () => {
await expect(async () => {
await act(() => {
root.render(<RenderTestUndefinedRender />);
});
}).toThrow();
}).rejects.toThrow();
}).toErrorDev([
'Warning: No `render` method found on the RenderTestUndefinedRender instance: ' +
'you may have forgotten to define `render`.',

View File

@ -166,6 +166,9 @@ describe('ReactDOM', () => {
// @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;
@ -180,40 +183,55 @@ describe('ReactDOM', () => {
}
const myDiv = document.createElement('div');
expect(() => {
expect(() => {
ReactDOM.render(<A />, myDiv, 'no');
}).toErrorDev(
'Expected the last optional `callback` argument to be ' +
'a function. Instead received: no.',
await expect(async () => {
await expect(async () => {
await act(() => {
ReactDOM.render(<A />, myDiv, 'no');
});
}).rejects.toThrowError(
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: no',
);
}).toThrowError(
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: no',
}).toErrorDev(
[
'Warning: Expected the last optional `callback` argument to be a function. Instead received: no.',
'Warning: Expected the last optional `callback` argument to be a function. Instead received: no.',
],
{withoutStack: 2},
);
expect(() => {
expect(() => {
ReactDOM.render(<A />, myDiv, {foo: 'bar'});
}).toErrorDev(
'Expected the last optional `callback` argument to be ' +
'a function. Instead received: [object Object].',
await expect(async () => {
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]',
);
}).toThrowError(
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: [object Object]',
}).toErrorDev(
[
'Expected the last optional `callback` argument to be a function. Instead received: [object Object].',
'Expected the last optional `callback` argument to be a function. Instead received: [object Object].',
],
{withoutStack: 2},
);
expect(() => {
expect(() => {
ReactDOM.render(<A />, myDiv, new Foo());
}).toErrorDev(
'Expected the last optional `callback` argument to be ' +
'a function. Instead received: [object Object].',
await expect(async () => {
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]',
);
}).toThrowError(
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: [object Object]',
}).toErrorDev(
[
'Expected the last optional `callback` argument to be a function. Instead received: [object Object].',
'Expected the last optional `callback` argument to be a function. Instead received: [object Object].',
],
{withoutStack: 2},
);
});
@ -234,42 +252,57 @@ describe('ReactDOM', () => {
const myDiv = document.createElement('div');
ReactDOM.render(<A />, myDiv);
expect(() => {
expect(() => {
ReactDOM.render(<A />, myDiv, 'no');
}).toErrorDev(
'Expected the last optional `callback` argument to be ' +
'a function. Instead received: no.',
await expect(async () => {
await expect(async () => {
await act(() => {
ReactDOM.render(<A />, myDiv, 'no');
});
}).rejects.toThrowError(
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: no',
);
}).toThrowError(
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: no',
}).toErrorDev(
[
'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
expect(() => {
expect(() => {
ReactDOM.render(<A />, myDiv, {foo: 'bar'});
}).toErrorDev(
'Expected the last optional `callback` argument to be ' +
'a function. Instead received: [object Object].',
await expect(async () => {
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]',
);
}).toThrowError(
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: [object Object]',
}).toErrorDev(
[
'Expected the last optional `callback` argument to be a function. Instead received: [object Object].',
'Expected the last optional `callback` argument to be a function. Instead received: [object Object].',
],
{withoutStack: 2},
);
ReactDOM.render(<A />, myDiv); // Re-mount
expect(() => {
expect(() => {
ReactDOM.render(<A />, myDiv, new Foo());
}).toErrorDev(
'Expected the last optional `callback` argument to be ' +
'a function. Instead received: [object Object].',
await expect(async () => {
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]',
);
}).toThrowError(
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: [object Object]',
}).toErrorDev(
[
'Expected the last optional `callback` argument to be a function. Instead received: [object Object].',
'Expected the last optional `callback` argument to be a function. Instead received: [object Object].',
],
{withoutStack: 2},
);
});

View File

@ -16,16 +16,14 @@ describe('ReactDOMConsoleErrorReporting', () => {
let NoError;
let container;
let windowOnError;
let waitForThrow;
let Scheduler;
beforeEach(() => {
jest.resetModules();
act = require('internal-test-utils').act;
React = require('react');
ReactDOMClient = require('react-dom/client');
const InternalTestUtils = require('internal-test-utils');
waitForThrow = InternalTestUtils.waitForThrow;
Scheduler = require('scheduler');
ErrorBoundary = class extends React.Component {
state = {error: null};
@ -46,6 +44,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
document.body.appendChild(container);
windowOnError = jest.fn();
window.addEventListener('error', windowOnError);
spyOnDevAndProd(console, 'error').mockImplementation(() => {});
spyOnDevAndProd(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
@ -54,11 +54,14 @@ describe('ReactDOMConsoleErrorReporting', () => {
jest.restoreAllMocks();
});
async function fakeAct(cb) {
// We don't use act/waitForThrow here because we want to observe how errors are reported for real.
await cb();
Scheduler.unstable_flushAll();
}
describe('ReactDOMClient.createRoot', () => {
it('logs errors during event handlers', async () => {
const originalError = console.error;
console.error = jest.fn();
function Foo() {
return (
<button
@ -75,13 +78,11 @@ describe('ReactDOMConsoleErrorReporting', () => {
root.render(<Foo />);
});
await act(() => {
container.firstChild.dispatchEvent(
new MouseEvent('click', {
bubbles: true,
}),
);
});
container.firstChild.dispatchEvent(
new MouseEvent('click', {
bubbles: true,
}),
);
expect(windowOnError.mock.calls).toEqual([
[
@ -95,58 +96,64 @@ describe('ReactDOMConsoleErrorReporting', () => {
[
// Reported because we're in a browser click event:
expect.objectContaining({
detail: expect.objectContaining({
message: 'Boom',
}),
type: 'unhandled exception',
message: 'Boom',
}),
],
]);
// Check next render doesn't throw.
windowOnError.mockReset();
console.error = originalError;
console.error.mockReset();
await act(() => {
root.render(<NoError />);
});
expect(container.textContent).toBe('OK');
expect(windowOnError.mock.calls).toEqual([]);
expect(console.error.mock.calls).toEqual([]);
});
it('logs render errors without an error boundary', async () => {
spyOnDevAndProd(console, 'error');
function Foo() {
throw Error('Boom');
}
const root = ReactDOMClient.createRoot(container);
await act(async () => {
await fakeAct(() => {
root.render(<Foo />);
await waitForThrow('Boom');
});
if (__DEV__) {
expect(windowOnError.mock.calls).toEqual([]);
expect(console.error.mock.calls).toEqual([
expect(windowOnError.mock.calls).toEqual([
[
// Formatting
expect.stringContaining('%o'),
expect.objectContaining({
message: 'Boom',
}),
],
]);
expect(console.error.mock.calls).toEqual([
[
expect.objectContaining({
message: 'Boom',
}),
],
]);
expect(console.warn.mock.calls).toEqual([
[
// Addendum by React:
expect.stringContaining(
'The above error occurred in the <Foo> component',
),
expect.stringContaining('%s'),
expect.stringContaining('An error occurred in the <Foo> component'),
expect.stringContaining('Foo'),
expect.stringContaining('Consider adding an error boundary'),
],
]);
} else {
// The top-level error was caught with try/catch,
// so in production we don't see an error event.
expect(windowOnError.mock.calls).toEqual([]);
expect(windowOnError.mock.calls).toEqual([
[
expect.objectContaining({
message: 'Boom',
}),
],
]);
expect(console.error.mock.calls).toEqual([
[
// Reported by React with no extra message:
@ -155,6 +162,7 @@ describe('ReactDOMConsoleErrorReporting', () => {
}),
],
]);
expect(console.warn.mock.calls).toEqual([]);
}
// Check next render doesn't throw.
@ -241,24 +249,30 @@ describe('ReactDOMConsoleErrorReporting', () => {
}
const root = ReactDOMClient.createRoot(container);
await act(async () => {
await fakeAct(() => {
root.render(<Foo />);
await waitForThrow('Boom');
});
if (__DEV__) {
expect(windowOnError.mock.calls).toEqual([]);
expect(console.error.mock.calls).toEqual([
expect(windowOnError.mock.calls).toEqual([
[
// Formatting
expect.stringContaining('%o'),
expect.objectContaining({
message: 'Boom',
}),
],
]);
expect(console.error.mock.calls).toEqual([
[
expect.objectContaining({
message: 'Boom',
}),
],
]);
expect(console.warn.mock.calls).toEqual([
[
// Addendum by React:
expect.stringContaining(
'The above error occurred in the <Foo> component',
),
expect.stringContaining('%s'),
expect.stringContaining('An error occurred in the <Foo> component'),
expect.stringContaining('Foo'),
expect.stringContaining('Consider adding an error boundary'),
],
@ -266,7 +280,13 @@ describe('ReactDOMConsoleErrorReporting', () => {
} else {
// The top-level error was caught with try/catch,
// so in production we don't see an error event.
expect(windowOnError.mock.calls).toEqual([]);
expect(windowOnError.mock.calls).toEqual([
[
expect.objectContaining({
message: 'Boom',
}),
],
]);
expect(console.error.mock.calls).toEqual([
[
// Reported by React with no extra message:
@ -275,6 +295,7 @@ describe('ReactDOMConsoleErrorReporting', () => {
}),
],
]);
expect(console.warn.mock.calls).toEqual([]);
}
// Check next render doesn't throw.
@ -364,32 +385,42 @@ describe('ReactDOMConsoleErrorReporting', () => {
}
const root = ReactDOMClient.createRoot(container);
await act(async () => {
await fakeAct(() => {
root.render(<Foo />);
await waitForThrow('Boom');
});
if (__DEV__) {
expect(windowOnError.mock.calls).toEqual([]);
expect(console.error.mock.calls).toEqual([
expect(windowOnError.mock.calls).toEqual([
[
// Formatting
expect.stringContaining('%o'),
expect.objectContaining({
message: 'Boom',
}),
],
]);
expect(console.error.mock.calls).toEqual([
[
expect.objectContaining({
message: 'Boom',
}),
],
]);
expect(console.warn.mock.calls).toEqual([
[
// Addendum by React:
expect.stringContaining(
'The above error occurred in the <Foo> component',
),
expect.stringContaining('%s'),
expect.stringContaining('An error occurred in the <Foo> component'),
expect.stringContaining('Foo'),
expect.stringContaining('Consider adding an error boundary'),
],
]);
} else {
// The top-level error was caught with try/catch,
// so in production we don't see an error event.
expect(windowOnError.mock.calls).toEqual([]);
expect(windowOnError.mock.calls).toEqual([
[
expect.objectContaining({
message: 'Boom',
}),
],
]);
expect(console.error.mock.calls).toEqual([
[
// Reported by React with no extra message:
@ -398,6 +429,7 @@ describe('ReactDOMConsoleErrorReporting', () => {
}),
],
]);
expect(console.warn.mock.calls).toEqual([]);
}
// Check next render doesn't throw.

View File

@ -46,6 +46,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
document.body.appendChild(container);
windowOnError = jest.fn();
window.addEventListener('error', windowOnError);
spyOnDevAndProd(console, 'error');
spyOnDevAndProd(console, 'warn');
});
afterEach(() => {
@ -57,9 +59,6 @@ describe('ReactDOMConsoleErrorReporting', () => {
describe('ReactDOM.render', () => {
// @gate !disableLegacyMode
it('logs errors during event handlers', async () => {
const originalError = console.error;
console.error = jest.fn();
function Foo() {
return (
<button
@ -75,69 +74,52 @@ describe('ReactDOMConsoleErrorReporting', () => {
ReactDOM.render(<Foo />, container);
});
await act(() => {
container.firstChild.dispatchEvent(
new MouseEvent('click', {
bubbles: true,
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(windowOnError.mock.calls).toEqual([
[
// Reported because we're in a browser click event:
expect.objectContaining({
message: 'Boom',
}),
],
]);
expect(console.error.mock.calls).toEqual([
[
expect.stringContaining(
'ReactDOM.render has not been supported since React 18',
),
],
[
// Reported because we're in a browser click event:
expect.objectContaining({
detail: expect.objectContaining({
message: 'Boom',
}),
type: 'unhandled exception',
}),
],
]);
} else {
expect(windowOnError.mock.calls).toEqual([
[
// Reported because we're in a browser click event:
expect.objectContaining({
message: 'Boom',
}),
],
]);
expect(console.error.mock.calls).toEqual([
[
// Reported because we're in a browser click event:
expect.objectContaining({
detail: expect.objectContaining({
message: 'Boom',
}),
type: 'unhandled exception',
}),
],
]);
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.mock.calls).toEqual([]);
expect(windowOnError).not.toBeCalled();
expect(console.warn).not.toBeCalled();
if (__DEV__) {
expect(console.error.mock.calls).toEqual([
[
@ -146,66 +128,66 @@ describe('ReactDOMConsoleErrorReporting', () => {
),
],
]);
} else {
expect(console.error).not.toBeCalled();
}
console.error = originalError;
});
// @gate !disableLegacyMode
it('logs render errors without an error boundary', async () => {
spyOnDevAndProd(console, 'error');
function Foo() {
throw Error('Boom');
}
expect(() => {
ReactDOM.render(<Foo />, container);
}).toThrow('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('Foo'),
expect.stringContaining('Consider adding an error boundary'),
],
]);
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('Foo'),
expect.stringContaining('Consider adding an error boundary'),
],
]);
} else {
// The top-level error was caught with try/catch,
// so in production we don't see an error event.
expect(windowOnError.mock.calls).toEqual([]);
expect(console.error.mock.calls).toEqual([
[
// Reported by React with no extra message:
expect.objectContaining({
message: 'Boom',
}),
],
]);
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.mock.calls).toEqual([]);
expect(console.warn).not.toBeCalled();
expect(windowOnError).not.toBeCalled();
if (__DEV__) {
expect(console.error.mock.calls).toEqual([
[
@ -214,13 +196,13 @@ describe('ReactDOMConsoleErrorReporting', () => {
),
],
]);
} else {
expect(console.error).not.toBeCalled();
}
});
// @gate !disableLegacyMode
it('logs render errors with an error boundary', async () => {
spyOnDevAndProd(console, 'error');
function Foo() {
throw Error('Boom');
}
@ -234,8 +216,12 @@ describe('ReactDOMConsoleErrorReporting', () => {
);
});
// 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(windowOnError.mock.calls).toEqual([]);
expect(console.error.mock.calls).toEqual([
[
expect.stringContaining(
@ -257,9 +243,6 @@ describe('ReactDOMConsoleErrorReporting', () => {
],
]);
} else {
// The top-level error was caught with try/catch,
// so in production we don't see an error event.
expect(windowOnError.mock.calls).toEqual([]);
expect(console.error.mock.calls).toEqual([
[
// Reported by React with no extra message:
@ -273,11 +256,13 @@ describe('ReactDOMConsoleErrorReporting', () => {
// 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.mock.calls).toEqual([]);
expect(windowOnError).not.toBeCalled();
expect(console.warn).not.toBeCalled();
if (__DEV__) {
expect(console.error.mock.calls).toEqual([
[
@ -286,13 +271,13 @@ describe('ReactDOMConsoleErrorReporting', () => {
),
],
]);
} else {
expect(console.error).not.toBeCalled();
}
});
// @gate !disableLegacyMode
it('logs layout effect errors without an error boundary', async () => {
spyOnDevAndProd(console, 'error');
function Foo() {
React.useLayoutEffect(() => {
throw Error('Boom');
@ -300,54 +285,59 @@ describe('ReactDOMConsoleErrorReporting', () => {
return null;
}
expect(() => {
ReactDOM.render(<Foo />, container);
}).toThrow('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(windowOnError.mock.calls).toEqual([]);
expect(console.error.mock.calls).toEqual([
[
expect.stringContaining(
'ReactDOM.render has not been supported since React 18',
),
],
expect(console.warn.mock.calls).toEqual([
[
// Formatting
expect.stringContaining('%o'),
expect.objectContaining({
message: 'Boom',
}),
expect.stringContaining('%s'),
// Addendum by React:
expect.stringContaining(
'The above error occurred in the <Foo> component',
'An error occurred in the <Foo> component:',
),
expect.stringContaining('Foo'),
expect.stringContaining('Consider adding an error boundary'),
],
]);
} else {
// The top-level error was caught with try/catch,
// so in production we don't see an error event.
expect(windowOnError.mock.calls).toEqual([]);
expect(console.error.mock.calls).toEqual([
[
// Reported by React with no extra message:
expect.objectContaining({
message: 'Boom',
}),
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.mock.calls).toEqual([]);
expect(console.warn).not.toBeCalled();
expect(windowOnError).not.toBeCalled();
if (__DEV__) {
expect(console.error.mock.calls).toEqual([
[
@ -356,13 +346,13 @@ describe('ReactDOMConsoleErrorReporting', () => {
),
],
]);
} else {
expect(console.error).not.toBeCalled();
}
});
// @gate !disableLegacyMode
it('logs layout effect errors with an error boundary', async () => {
spyOnDevAndProd(console, 'error');
function Foo() {
React.useLayoutEffect(() => {
throw Error('Boom');
@ -379,8 +369,12 @@ describe('ReactDOMConsoleErrorReporting', () => {
);
});
// 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(windowOnError.mock.calls).toEqual([]);
expect(console.error.mock.calls).toEqual([
[
expect.stringContaining(
@ -402,9 +396,6 @@ describe('ReactDOMConsoleErrorReporting', () => {
],
]);
} else {
// The top-level error was caught with try/catch,
// so in production we don't see an error event.
expect(windowOnError.mock.calls).toEqual([]);
expect(console.error.mock.calls).toEqual([
[
// Reported by React with no extra message:
@ -417,12 +408,14 @@ describe('ReactDOMConsoleErrorReporting', () => {
// 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.mock.calls).toEqual([]);
expect(windowOnError).not.toBeCalled();
expect(console.warn).not.toBeCalled();
if (__DEV__) {
expect(console.error.mock.calls).toEqual([
[
@ -431,13 +424,13 @@ describe('ReactDOMConsoleErrorReporting', () => {
),
],
]);
} else {
expect(console.error).not.toBeCalled();
}
});
// @gate !disableLegacyMode
it('logs passive effect errors without an error boundary', async () => {
spyOnDevAndProd(console, 'error');
function Foo() {
React.useEffect(() => {
throw Error('Boom');
@ -450,50 +443,51 @@ describe('ReactDOMConsoleErrorReporting', () => {
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(windowOnError.mock.calls).toEqual([]);
expect(console.warn.mock.calls).toEqual([
[
// Formatting
expect.stringContaining('%s'),
// Addendum by React:
expect.stringContaining('An error occurred in the <Foo> component'),
expect.stringContaining('Foo'),
expect.stringContaining('Consider adding an error boundary'),
],
]);
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('Foo'),
expect.stringContaining('Consider adding an error boundary'),
],
]);
} else {
// The top-level error was caught with try/catch,
// so in production we don't see an error event.
expect(windowOnError.mock.calls).toEqual([]);
expect(console.error.mock.calls).toEqual([
[
// Reported by React with no extra message:
expect.objectContaining({
message: 'Boom',
}),
],
]);
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.mock.calls).toEqual([]);
expect(windowOnError).not.toBeCalled();
expect(console.warn).not.toBeCalled();
if (__DEV__) {
expect(console.error.mock.calls).toEqual([
[
@ -502,13 +496,13 @@ describe('ReactDOMConsoleErrorReporting', () => {
),
],
]);
} else {
expect(console.error).not.toBeCalled();
}
});
// @gate !disableLegacyMode
it('logs passive effect errors with an error boundary', async () => {
spyOnDevAndProd(console, 'error');
function Foo() {
React.useEffect(() => {
throw Error('Boom');
@ -525,8 +519,12 @@ describe('ReactDOMConsoleErrorReporting', () => {
);
});
// 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(windowOnError.mock.calls).toEqual([]);
expect(console.error.mock.calls).toEqual([
[
expect.stringContaining(
@ -548,9 +546,6 @@ describe('ReactDOMConsoleErrorReporting', () => {
],
]);
} else {
// The top-level error was caught with try/catch,
// so in production we don't see an error event.
expect(windowOnError.mock.calls).toEqual([]);
expect(console.error.mock.calls).toEqual([
[
// Reported by React with no extra message:
@ -563,12 +558,14 @@ describe('ReactDOMConsoleErrorReporting', () => {
// 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.mock.calls).toEqual([]);
expect(windowOnError).not.toBeCalled();
expect(console.warn).not.toBeCalled();
if (__DEV__) {
expect(console.error.mock.calls).toEqual([
[
@ -577,6 +574,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
),
],
]);
} else {
expect(console.warn).not.toBeCalled();
}
});
});

View File

@ -1108,11 +1108,13 @@ describe('ReactDOMFiber', () => {
// It's an error of type 'NotFoundError' with no message
container.innerHTML = '<div>MEOW.</div>';
expect(() => {
ReactDOM.flushSync(() => {
root.render(<div key="2">baz</div>);
await expect(async () => {
await act(() => {
ReactDOM.flushSync(() => {
root.render(<div key="2">baz</div>);
});
});
}).toThrow('The node to be removed is not a child of this node');
}).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 () => {

View File

@ -17,6 +17,10 @@ let act;
const util = require('util');
const realConsoleError = console.error;
function errorHandler() {
// forward to console.error but don't fail the tests
}
describe('ReactDOMServerHydration', () => {
let container;
@ -27,12 +31,14 @@ describe('ReactDOMServerHydration', () => {
ReactDOMServer = require('react-dom/server');
act = React.act;
window.addEventListener('error', errorHandler);
console.error = jest.fn();
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
window.removeEventListener('error', errorHandler);
document.body.removeChild(container);
console.error = realConsoleError;
});

View File

@ -12,11 +12,12 @@
const React = require('react');
const ReactDOM = require('react-dom');
const PropTypes = require('prop-types');
let act;
describe('ReactDOMLegacyFiber', () => {
let container;
beforeEach(() => {
act = require('internal-test-utils').act;
container = document.createElement('div');
document.body.appendChild(container);
});
@ -656,18 +657,20 @@ describe('ReactDOMLegacyFiber', () => {
});
// @gate !disableLegacyMode
it('should unwind namespaces on uncaught errors', () => {
it('should unwind namespaces on uncaught errors', async () => {
function BrokenRender() {
throw new Error('Hello');
}
expect(() => {
assertNamespacesMatch(
<svg {...expectSVG}>
<BrokenRender />
</svg>,
);
}).toThrow('Hello');
await expect(async () => {
await act(() => {
assertNamespacesMatch(
<svg {...expectSVG}>
<BrokenRender />
</svg>,
);
});
}).rejects.toThrow('Hello');
assertNamespacesMatch(<div {...expectHTML} />);
});
@ -1222,7 +1225,7 @@ describe('ReactDOMLegacyFiber', () => {
});
// @gate !disableLegacyMode
it('should warn when replacing a container which was manually updated outside of React', () => {
it('should warn when replacing a container which was manually updated outside of React', async () => {
// when not messing with the DOM outside of React
ReactDOM.render(<div key="1">foo</div>, container);
ReactDOM.render(<div key="1">bar</div>, container);
@ -1232,18 +1235,20 @@ describe('ReactDOMLegacyFiber', () => {
// It's an error of type 'NotFoundError' with no message
container.innerHTML = '<div>MEOW.</div>';
expect(() => {
expect(() =>
ReactDOM.render(<div key="2">baz</div>, container),
).toErrorDev(
'' +
'It looks like the React-rendered content of this container was ' +
'removed without using React. This is not supported and will ' +
'cause errors. Instead, call ReactDOM.unmountComponentAtNode ' +
'to empty a container.',
{withoutStack: true},
);
}).toThrowError();
await expect(async () => {
await expect(async () => {
await act(() => {
ReactDOM.render(<div key="2">baz</div>, container);
});
}).rejects.toThrow('The node to be removed is not a child of this node.');
}).toErrorDev(
'' +
'It looks like the React-rendered content of this container was ' +
'removed without using React. This is not supported and will ' +
'cause errors. Instead, call ReactDOM.unmountComponentAtNode ' +
'to empty a container.',
{withoutStack: true},
);
});
// @gate !disableLegacyMode

View File

@ -319,9 +319,11 @@ describe('ReactDOMRoot', () => {
});
container.innerHTML = '';
expect(() => {
root.unmount();
}).toThrow('The node to be removed is not a child of this node.');
await expect(async () => {
await act(() => {
root.unmount();
});
}).rejects.toThrow('The node to be removed is not a child of this node.');
});
it('opts-in to concurrent default updates', async () => {

View File

@ -1448,7 +1448,13 @@ describe('ReactDOMSelect', () => {
</select>,
);
}),
).rejects.toThrowError(new TypeError('prod message'));
).rejects.toThrowError(
// eslint-disable-next-line no-undef
new AggregateError([
new TypeError('prod message'),
new TypeError('prod message'),
]),
);
}).toErrorDev([
'The provided `value` attribute is an unsupported type TemporalLike.' +
' This value must be coerced to a string before using it here.',

View File

@ -330,7 +330,6 @@ describe('ReactDOMServerPartialHydration', () => {
'Component',
'Component',
'Component',
// Hydration mismatch is logged
"Hydration failed because the server rendered HTML didn't match the client.",
'There was an error while hydrating this Suspense boundary.',
@ -1151,16 +1150,20 @@ describe('ReactDOMServerPartialHydration', () => {
shouldSuspend = true;
await act(() => {
ReactDOMClient.hydrateRoot(container, <App hasB={false} />);
ReactDOMClient.hydrateRoot(container, <App hasB={false} />, {
onRecoverableError(error) {
Scheduler.log(normalizeError(error.message));
},
});
});
await expect(async () => {
await act(() => {
resolve();
});
}).toErrorDev([
await act(() => {
resolve();
});
assertLog([
"Hydration failed because the server rendered HTML didn't match the client.",
'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
'There was an error while hydrating this Suspense boundary.',
]);
expect(container.innerHTML).toContain('<span>A</span>');

View File

@ -643,7 +643,7 @@ describe('ReactErrorBoundaries', () => {
root.render(<BrokenComponentWillUnmount />);
});
await expect(async () => {
root.unmount();
await act(() => root.unmount());
}).rejects.toThrow('Hello');
});
@ -2470,7 +2470,7 @@ describe('ReactErrorBoundaries', () => {
]);
});
it('passes first error when two errors happen in commit', async () => {
it('passes an aggregate error when two errors happen in commit', async () => {
const errors = [];
let caughtError;
class Parent extends React.Component {
@ -2501,15 +2501,14 @@ describe('ReactErrorBoundaries', () => {
root.render(<Parent />);
});
} catch (e) {
if (e.message !== 'parent sad' && e.message !== 'child sad') {
throw e;
}
caughtError = e;
}
expect(errors).toEqual(['child sad', 'parent sad']);
// Error should be the first thrown
expect(caughtError.message).toBe('child sad');
expect(caughtError.errors).toEqual([
expect.objectContaining({message: 'child sad'}),
expect.objectContaining({message: 'parent sad'}),
]);
});
it('propagates uncaught error inside unbatched initial mount', async () => {
@ -2561,15 +2560,14 @@ describe('ReactErrorBoundaries', () => {
root.render(<Parent value={2} />);
});
} catch (e) {
if (e.message !== 'parent sad' && e.message !== 'child sad') {
throw e;
}
caughtError = e;
}
expect(errors).toEqual(['child sad', 'parent sad']);
// Error should be the first thrown
expect(caughtError.message).toBe('child sad');
expect(caughtError.errors).toEqual([
expect.objectContaining({message: 'child sad'}),
expect.objectContaining({message: 'parent sad'}),
]);
});
it('should warn if an error boundary with only componentDidCatch does not update state', async () => {

View File

@ -18,7 +18,7 @@ if (global.window) {
// The issue only reproduced when React was loaded before JSDOM.
const React = require('react');
const ReactDOMClient = require('react-dom/client');
const act = require('internal-test-utils').act;
const Scheduler = require('scheduler');
// Initialize JSDOM separately.
// We don't use our normal JSDOM setup because we want to load React first.
@ -39,6 +39,12 @@ class Bad extends React.Component {
}
}
async function fakeAct(cb) {
// We don't use act/waitForThrow here because we want to observe how errors are reported for real.
await cb();
Scheduler.unstable_flushAll();
}
describe('ReactErrorLoggingRecovery', () => {
const originalConsoleError = console.error;
@ -55,20 +61,18 @@ describe('ReactErrorLoggingRecovery', () => {
it('should recover from errors in console.error', async function () {
const div = document.createElement('div');
const root = ReactDOMClient.createRoot(div);
await expect(async () => {
await act(() => {
root.render(<Bad />);
});
await act(() => {
root.render(<Bad />);
});
}).rejects.toThrow('no');
await fakeAct(() => {
root.render(<Bad />);
});
await fakeAct(() => {
root.render(<Bad />);
});
await expect(async () => {
await act(() => {
root.render(<span>Hello</span>);
});
}).rejects.toThrow('Buggy console.error');
expect(() => jest.runAllTimers()).toThrow('');
await fakeAct(() => {
root.render(<span>Hello</span>);
});
expect(div.firstChild.textContent).toBe('Hello');
});
});

View File

@ -12,6 +12,7 @@
let PropTypes;
let React;
let ReactDOM;
let act;
// TODO: Refactor this test once componentDidCatch setState is deprecated.
describe('ReactLegacyErrorBoundaries', () => {
@ -40,6 +41,7 @@ describe('ReactLegacyErrorBoundaries', () => {
PropTypes = require('prop-types');
ReactDOM = require('react-dom');
React = require('react');
act = require('internal-test-utils').act;
log = [];
@ -586,63 +588,79 @@ describe('ReactLegacyErrorBoundaries', () => {
});
// @gate !disableLegacyMode
it('does not swallow exceptions on mounting without boundaries', () => {
it('does not swallow exceptions on mounting without boundaries', async () => {
let container = document.createElement('div');
expect(() => {
ReactDOM.render(<BrokenRender />, container);
}).toThrow('Hello');
await expect(async () => {
await act(() => {
ReactDOM.render(<BrokenRender />, container);
});
}).rejects.toThrow('Hello');
container = document.createElement('div');
expect(() => {
ReactDOM.render(<BrokenComponentWillMount />, container);
}).toThrow('Hello');
await expect(async () => {
await act(() => {
ReactDOM.render(<BrokenComponentWillMount />, container);
});
}).rejects.toThrow('Hello');
container = document.createElement('div');
expect(() => {
ReactDOM.render(<BrokenComponentDidMount />, container);
}).toThrow('Hello');
await expect(async () => {
await act(() => {
ReactDOM.render(<BrokenComponentDidMount />, container);
});
}).rejects.toThrow('Hello');
});
// @gate !disableLegacyMode
it('does not swallow exceptions on updating without boundaries', () => {
it('does not swallow exceptions on updating without boundaries', async () => {
let container = document.createElement('div');
ReactDOM.render(<BrokenComponentWillUpdate />, container);
expect(() => {
ReactDOM.render(<BrokenComponentWillUpdate />, container);
}).toThrow('Hello');
await expect(async () => {
await act(() => {
ReactDOM.render(<BrokenComponentWillUpdate />, container);
});
}).rejects.toThrow('Hello');
container = document.createElement('div');
ReactDOM.render(<BrokenComponentWillReceiveProps />, container);
expect(() => {
ReactDOM.render(<BrokenComponentWillReceiveProps />, container);
}).toThrow('Hello');
await expect(async () => {
await act(() => {
ReactDOM.render(<BrokenComponentWillReceiveProps />, container);
});
}).rejects.toThrow('Hello');
container = document.createElement('div');
ReactDOM.render(<BrokenComponentDidUpdate />, container);
expect(() => {
ReactDOM.render(<BrokenComponentDidUpdate />, container);
}).toThrow('Hello');
await expect(async () => {
await act(() => {
ReactDOM.render(<BrokenComponentDidUpdate />, container);
});
}).rejects.toThrow('Hello');
});
// @gate !disableLegacyMode
it('does not swallow exceptions on unmounting without boundaries', () => {
it('does not swallow exceptions on unmounting without boundaries', async () => {
const container = document.createElement('div');
ReactDOM.render(<BrokenComponentWillUnmount />, container);
expect(() => {
ReactDOM.unmountComponentAtNode(container);
}).toThrow('Hello');
await expect(async () => {
await act(() => {
ReactDOM.unmountComponentAtNode(container);
});
}).rejects.toThrow('Hello');
});
// @gate !disableLegacyMode
it('prevents errors from leaking into other roots', () => {
it('prevents errors from leaking into other roots', async () => {
const container1 = document.createElement('div');
const container2 = document.createElement('div');
const container3 = document.createElement('div');
ReactDOM.render(<span>Before 1</span>, container1);
expect(() => {
ReactDOM.render(<BrokenRender />, container2);
}).toThrow('Hello');
await expect(async () => {
await act(() => {
ReactDOM.render(<BrokenRender />, container2);
});
}).rejects.toThrow('Hello');
ReactDOM.render(
<ErrorBoundary>
<BrokenRender />
@ -2124,39 +2142,41 @@ describe('ReactLegacyErrorBoundaries', () => {
});
// @gate !disableLegacyMode
it('discards a bad root if the root component fails', () => {
it('discards a bad root if the root component fails', async () => {
const X = null;
const Y = undefined;
let err1;
let err2;
try {
const container = document.createElement('div');
expect(() => ReactDOM.render(<X />, container)).toErrorDev(
'React.createElement: type is invalid -- expected a string ' +
'(for built-in components) or a class/function ' +
'(for composite components) but got: null.',
);
} catch (err) {
err1 = err;
}
try {
const container = document.createElement('div');
expect(() => ReactDOM.render(<Y />, container)).toErrorDev(
'React.createElement: type is invalid -- expected a string ' +
'(for built-in components) or a class/function ' +
'(for composite components) but got: undefined.',
);
} catch (err) {
err2 = err;
}
await expect(async () => {
await expect(async () => {
const container = document.createElement('div');
await act(() => {
ReactDOM.render(<X />, container);
});
}).rejects.toThrow('got: null');
}).toErrorDev(
'Warning: React.jsx: type is invalid -- expected a string ' +
'(for built-in components) or a class/function ' +
'(for composite components) but got: null.',
{withoutStack: 1},
);
expect(err1.message).toMatch(/got: null/);
expect(err2.message).toMatch(/got: undefined/);
await expect(async () => {
await expect(async () => {
const container = document.createElement('div');
await act(() => {
ReactDOM.render(<Y />, container);
});
}).rejects.toThrow('got: undefined');
}).toErrorDev(
'Warning: React.jsx: type is invalid -- expected a string ' +
'(for built-in components) or a class/function ' +
'(for composite components) but got: undefined.',
{withoutStack: 1},
);
});
// @gate !disableLegacyMode
it('renders empty output if error boundary does not handle the error', () => {
it('renders empty output if error boundary does not handle the error', async () => {
const container = document.createElement('div');
expect(() => {
ReactDOM.render(
@ -2191,9 +2211,8 @@ describe('ReactLegacyErrorBoundaries', () => {
});
// @gate !disableLegacyMode
it('passes first error when two errors happen in commit', () => {
it('passes first error when two errors happen in commit', async () => {
const errors = [];
let caughtError;
class Parent extends React.Component {
render() {
return <Child />;
@ -2214,39 +2233,42 @@ describe('ReactLegacyErrorBoundaries', () => {
}
const container = document.createElement('div');
try {
// Here, we test the behavior where there is no error boundary and we
// delegate to the host root.
ReactDOM.render(<Parent />, container);
} catch (e) {
if (e.message !== 'parent sad' && e.message !== 'child sad') {
throw e;
}
caughtError = e;
}
await expect(async () => {
await act(() => {
// Here, we test the behavior where there is no error boundary and we
// delegate to the host root.
ReactDOM.render(<Parent />, container);
});
}).rejects.toThrow(
expect.objectContaining({
errors: [
expect.objectContaining({message: 'child sad'}),
expect.objectContaining({message: 'parent sad'}),
],
}),
);
expect(errors).toEqual(['child sad', 'parent sad']);
// Error should be the first thrown
expect(caughtError.message).toBe('child sad');
});
// @gate !disableLegacyMode
it('propagates uncaught error inside unbatched initial mount', () => {
it('propagates uncaught error inside unbatched initial mount', async () => {
function Foo() {
throw new Error('foo error');
}
const container = document.createElement('div');
expect(() => {
ReactDOM.unstable_batchedUpdates(() => {
ReactDOM.render(<Foo />, container);
await expect(async () => {
await act(() => {
ReactDOM.unstable_batchedUpdates(() => {
ReactDOM.render(<Foo />, container);
});
});
}).toThrow('foo error');
}).rejects.toThrow('foo error');
});
// @gate !disableLegacyMode
it('handles errors that occur in before-mutation commit hook', () => {
it('handles errors that occur in before-mutation commit hook', async () => {
const errors = [];
let caughtError;
class Parent extends React.Component {
getSnapshotBeforeUpdate() {
errors.push('parent sad');
@ -2269,18 +2291,24 @@ describe('ReactLegacyErrorBoundaries', () => {
}
const container = document.createElement('div');
ReactDOM.render(<Parent value={1} />, container);
try {
ReactDOM.render(<Parent value={2} />, container);
} catch (e) {
if (e.message !== 'parent sad' && e.message !== 'child sad') {
throw e;
}
caughtError = e;
}
await act(() => {
ReactDOM.render(<Parent value={1} />, container);
});
await expect(async () => {
await act(() => {
ReactDOM.render(<Parent value={2} />, container);
});
}).rejects.toThrow(
expect.objectContaining({
errors: [
expect.objectContaining({message: 'child sad'}),
expect.objectContaining({message: 'parent sad'}),
],
}),
);
expect(errors).toEqual(['child sad', 'parent sad']);
// Error should be the first thrown
expect(caughtError.message).toBe('child sad');
});
});

View File

@ -886,7 +886,7 @@ describe('ReactLegacyUpdates', () => {
});
// @gate !disableLegacyMode
it('throws in setState if the update callback is not a function', () => {
it('throws in setState if the update callback is not a function', async () => {
function Foo() {
this.a = 1;
this.b = 2;
@ -903,37 +903,52 @@ describe('ReactLegacyUpdates', () => {
let container = document.createElement('div');
let component = ReactDOM.render(<A />, container);
expect(() => {
expect(() => component.setState({}, 'no')).toErrorDev(
'Expected the last optional `callback` argument to be ' +
'a function. Instead received: no.',
await expect(async () => {
await expect(async () => {
await act(() => {
component.setState({}, 'no');
});
}).rejects.toThrowError(
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: no',
);
}).toThrowError(
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: no',
}).toErrorDev(
'Expected the last optional `callback` argument to be ' +
'a function. Instead received: no.',
{withoutStack: 1},
);
container = document.createElement('div');
component = ReactDOM.render(<A />, container);
expect(() => {
expect(() => component.setState({}, {foo: 'bar'})).toErrorDev(
'Expected the last optional `callback` argument to be ' +
'a function. Instead received: [object Object].',
await expect(async () => {
await expect(async () => {
await act(() => {
component.setState({}, {foo: 'bar'});
});
}).rejects.toThrowError(
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: [object Object]',
);
}).toThrowError(
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: [object Object]',
}).toErrorDev(
'Expected the last optional `callback` argument to be ' +
'a function. Instead received: [object Object].',
{withoutStack: 1},
);
// Make sure the warning is deduplicated and doesn't fire again
container = document.createElement('div');
component = ReactDOM.render(<A />, container);
expect(() => component.setState({}, new Foo())).toThrowError(
await expect(async () => {
await act(() => {
component.setState({}, new Foo());
});
}).rejects.toThrowError(
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: [object Object]',
);
});
// @gate !disableLegacyMode
it('throws in forceUpdate if the update callback is not a function', () => {
it('throws in forceUpdate if the update callback is not a function', async () => {
function Foo() {
this.a = 1;
this.b = 2;
@ -950,30 +965,44 @@ describe('ReactLegacyUpdates', () => {
let container = document.createElement('div');
let component = ReactDOM.render(<A />, container);
expect(() => {
expect(() => component.forceUpdate('no')).toErrorDev(
'Expected the last optional `callback` argument to be ' +
'a function. Instead received: no.',
await expect(async () => {
await expect(async () => {
await act(() => {
component.forceUpdate('no');
});
}).rejects.toThrowError(
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: no',
);
}).toThrowError(
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: no',
}).toErrorDev(
'Expected the last optional `callback` argument to be ' +
'a function. Instead received: no.',
{withoutStack: 1},
);
container = document.createElement('div');
component = ReactDOM.render(<A />, container);
expect(() => {
expect(() => component.forceUpdate({foo: 'bar'})).toErrorDev(
'Expected the last optional `callback` argument to be ' +
'a function. Instead received: [object Object].',
await expect(async () => {
await expect(async () => {
await act(() => {
component.forceUpdate({foo: 'bar'});
});
}).rejects.toThrowError(
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: [object Object]',
);
}).toThrowError(
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: [object Object]',
}).toErrorDev(
'Expected the last optional `callback` argument to be ' +
'a function. Instead received: [object Object].',
{withoutStack: 1},
);
// Make sure the warning is deduplicated and doesn't fire again
container = document.createElement('div');
component = ReactDOM.render(<A />, container);
expect(() => component.forceUpdate(new Foo())).toThrowError(
await expect(async () => {
await act(() => {
component.forceUpdate(new Foo());
});
}).rejects.toThrowError(
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: [object Object]',
);
@ -1377,7 +1406,7 @@ describe('ReactLegacyUpdates', () => {
});
// @gate !disableLegacyMode
it('resets the update counter for unrelated updates', () => {
it('resets the update counter for unrelated updates', async () => {
const container = document.createElement('div');
const ref = React.createRef();
@ -1397,9 +1426,11 @@ describe('ReactLegacyUpdates', () => {
}
let limit = 55;
expect(() => {
ReactDOM.render(<EventuallyTerminating ref={ref} />, container);
}).toThrow('Maximum');
await expect(async () => {
await act(() => {
ReactDOM.render(<EventuallyTerminating ref={ref} />, container);
});
}).rejects.toThrow('Maximum');
// Verify that we don't go over the limit if these updates are unrelated.
limit -= 10;
@ -1411,14 +1442,16 @@ describe('ReactLegacyUpdates', () => {
expect(container.textContent).toBe(limit.toString());
limit += 10;
expect(() => {
ref.current.setState({step: 0});
}).toThrow('Maximum');
await expect(async () => {
await act(() => {
ref.current.setState({step: 0});
});
}).rejects.toThrow('Maximum');
expect(ref.current).toBe(null);
});
// @gate !disableLegacyMode
it('does not fall into an infinite update loop', () => {
it('does not fall into an infinite update loop', async () => {
class NonTerminating extends React.Component {
state = {step: 0};
componentDidMount() {
@ -1438,13 +1471,15 @@ describe('ReactLegacyUpdates', () => {
}
const container = document.createElement('div');
expect(() => {
ReactDOM.render(<NonTerminating />, container);
}).toThrow('Maximum');
await expect(async () => {
await act(() => {
ReactDOM.render(<NonTerminating />, container);
});
}).rejects.toThrow('Maximum');
});
// @gate !disableLegacyMode
it('does not fall into an infinite update loop with useLayoutEffect', () => {
it('does not fall into an infinite update loop with useLayoutEffect', async () => {
function NonTerminating() {
const [step, setStep] = React.useState(0);
React.useLayoutEffect(() => {
@ -1454,13 +1489,15 @@ describe('ReactLegacyUpdates', () => {
}
const container = document.createElement('div');
expect(() => {
ReactDOM.render(<NonTerminating />, container);
}).toThrow('Maximum');
await expect(async () => {
await act(() => {
ReactDOM.render(<NonTerminating />, container);
});
}).rejects.toThrow('Maximum');
});
// @gate !disableLegacyMode
it('can recover after falling into an infinite update loop', () => {
it('can recover after falling into an infinite update loop', async () => {
class NonTerminating extends React.Component {
state = {step: 0};
componentDidMount() {
@ -1485,23 +1522,27 @@ describe('ReactLegacyUpdates', () => {
}
const container = document.createElement('div');
expect(() => {
ReactDOM.render(<NonTerminating />, container);
}).toThrow('Maximum');
await expect(async () => {
await act(() => {
ReactDOM.render(<NonTerminating />, container);
});
}).rejects.toThrow('Maximum');
ReactDOM.render(<Terminating />, container);
expect(container.textContent).toBe('1');
expect(() => {
ReactDOM.render(<NonTerminating />, container);
}).toThrow('Maximum');
await expect(async () => {
await act(() => {
ReactDOM.render(<NonTerminating />, container);
});
}).rejects.toThrow('Maximum');
ReactDOM.render(<Terminating />, container);
expect(container.textContent).toBe('1');
});
// @gate !disableLegacyMode
it('does not fall into mutually recursive infinite update loop with same container', () => {
it('does not fall into mutually recursive infinite update loop with same container', async () => {
// Note: this test would fail if there were two or more different roots.
class A extends React.Component {
@ -1523,13 +1564,15 @@ describe('ReactLegacyUpdates', () => {
}
const container = document.createElement('div');
expect(() => {
ReactDOM.render(<A />, container);
}).toThrow('Maximum');
await expect(async () => {
await act(() => {
ReactDOM.render(<A />, container);
});
}).rejects.toThrow('Maximum');
});
// @gate !disableLegacyMode
it('does not fall into an infinite error loop', () => {
it('does not fall into an infinite error loop', async () => {
function BadRender() {
throw new Error('error');
}
@ -1557,9 +1600,11 @@ describe('ReactLegacyUpdates', () => {
}
const container = document.createElement('div');
expect(() => {
ReactDOM.render(<NonTerminating />, container);
}).toThrow('Maximum');
await expect(async () => {
await act(() => {
ReactDOM.render(<NonTerminating />, container);
});
}).rejects.toThrow('Maximum');
});
// @gate !disableLegacyMode

View File

@ -1542,11 +1542,11 @@ describe('ReactUpdates', () => {
let limit = 55;
const root = ReactDOMClient.createRoot(container);
expect(() => {
ReactDOM.flushSync(() => {
await expect(async () => {
await act(() => {
root.render(<EventuallyTerminating ref={ref} />);
});
}).toThrow('Maximum');
}).rejects.toThrow('Maximum');
// Verify that we don't go over the limit if these updates are unrelated.
limit -= 10;
@ -1566,15 +1566,15 @@ describe('ReactUpdates', () => {
expect(container.textContent).toBe(limit.toString());
limit += 10;
expect(() => {
ReactDOM.flushSync(() => {
await expect(async () => {
await act(() => {
ref.current.setState({step: 0});
});
}).toThrow('Maximum');
}).rejects.toThrow('Maximum');
expect(ref.current).toBe(null);
});
it('does not fall into an infinite update loop', () => {
it('does not fall into an infinite update loop', async () => {
class NonTerminating extends React.Component {
state = {step: 0};
@ -1599,14 +1599,14 @@ describe('ReactUpdates', () => {
const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
expect(() => {
ReactDOM.flushSync(() => {
await expect(async () => {
await act(() => {
root.render(<NonTerminating />);
});
}).toThrow('Maximum');
}).rejects.toThrow('Maximum');
});
it('does not fall into an infinite update loop with useLayoutEffect', () => {
it('does not fall into an infinite update loop with useLayoutEffect', async () => {
function NonTerminating() {
const [step, setStep] = React.useState(0);
React.useLayoutEffect(() => {
@ -1617,11 +1617,11 @@ describe('ReactUpdates', () => {
const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
expect(() => {
ReactDOM.flushSync(() => {
await expect(async () => {
await act(() => {
root.render(<NonTerminating />);
});
}).toThrow('Maximum');
}).rejects.toThrow('Maximum');
});
it('can recover after falling into an infinite update loop', async () => {
@ -1650,29 +1650,29 @@ describe('ReactUpdates', () => {
const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
expect(() => {
ReactDOM.flushSync(() => {
await expect(async () => {
await act(() => {
root.render(<NonTerminating />);
});
}).toThrow('Maximum');
}).rejects.toThrow('Maximum');
await act(() => {
root.render(<Terminating />);
});
expect(container.textContent).toBe('1');
expect(() => {
ReactDOM.flushSync(() => {
await expect(async () => {
await act(() => {
root.render(<NonTerminating />);
});
}).toThrow('Maximum');
}).rejects.toThrow('Maximum');
await act(() => {
root.render(<Terminating />);
});
expect(container.textContent).toBe('1');
});
it('does not fall into mutually recursive infinite update loop with same container', () => {
it('does not fall into mutually recursive infinite update loop with same container', async () => {
// Note: this test would fail if there were two or more different roots.
const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
@ -1694,14 +1694,14 @@ describe('ReactUpdates', () => {
}
}
expect(() => {
ReactDOM.flushSync(() => {
await expect(async () => {
await act(() => {
root.render(<A />);
});
}).toThrow('Maximum');
}).rejects.toThrow('Maximum');
});
it('does not fall into an infinite error loop', () => {
it('does not fall into an infinite error loop', async () => {
function BadRender() {
throw new Error('error');
}
@ -1730,11 +1730,11 @@ describe('ReactUpdates', () => {
const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
expect(() => {
ReactDOM.flushSync(() => {
await expect(async () => {
await act(() => {
root.render(<NonTerminating />);
});
}).toThrow('Maximum');
}).rejects.toThrow('Maximum');
});
it('can schedule ridiculously many updates within the same batch without triggering a maximum update error', async () => {
@ -1775,7 +1775,7 @@ describe('ReactUpdates', () => {
expect(subscribers.length).toBe(limit);
});
it("does not infinite loop if there's a synchronous render phase update on another component", () => {
it("does not infinite loop if there's a synchronous render phase update on another component", async () => {
if (gate(flags => !flags.enableInfiniteRenderLoopDetection)) {
return;
}
@ -1795,10 +1795,10 @@ describe('ReactUpdates', () => {
const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
expect(() => {
expect(() => ReactDOM.flushSync(() => root.render(<App />))).toThrow(
'Maximum update depth exceeded',
);
await expect(async () => {
await expect(async () => {
await act(() => ReactDOM.flushSync(() => root.render(<App />)));
}).rejects.toThrow('Maximum update depth exceeded');
}).toErrorDev(
'Warning: Cannot update a component (`App`) while rendering a different component (`Child`)',
);
@ -1926,7 +1926,7 @@ describe('ReactUpdates', () => {
});
}
it('prevents infinite update loop triggered by synchronous updates in useEffect', () => {
it('prevents infinite update loop triggered by synchronous updates in useEffect', async () => {
// Ignore flushSync warning
spyOnDev(console, 'error').mockImplementation(() => {});
@ -1950,10 +1950,12 @@ describe('ReactUpdates', () => {
const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
expect(() => {
ReactDOM.flushSync(() => {
root.render(<NonTerminating />);
await expect(async () => {
await act(() => {
ReactDOM.flushSync(() => {
root.render(<NonTerminating />);
});
});
}).toThrow('Maximum update depth exceeded');
}).rejects.toThrow('Maximum update depth exceeded');
});
});

View File

@ -70,17 +70,11 @@ import {
} from 'react-reconciler/src/ReactFiberReconciler';
import {ConcurrentRoot} from 'react-reconciler/src/ReactRootTags';
/* global reportError */
const defaultOnRecoverableError =
typeof reportError === 'function'
? // In modern browsers, reportError will dispatch an error event,
// emulating an uncaught JavaScript error.
reportError
: (error: mixed) => {
// In older browsers and test environments, fallback to console.error.
// eslint-disable-next-line react-internal/no-production-logging
console['error'](error);
};
import reportGlobalError from 'shared/reportGlobalError';
function defaultOnRecoverableError(error: mixed, errorInfo: any) {
reportGlobalError(error);
}
// $FlowFixMe[missing-this-annot]
function ReactDOMRoot(internalRoot: FiberRoot) {

View File

@ -13,6 +13,7 @@
let PropTypes;
let RCTEventEmitter;
let React;
let act;
let ReactNative;
let ResponderEventPlugin;
let UIManager;
@ -67,6 +68,7 @@ beforeEach(() => {
RCTEventEmitter =
require('react-native/Libraries/ReactPrivate/ReactNativePrivateInterface').RCTEventEmitter;
React = require('react');
act = require('internal-test-utils').act;
ReactNative = require('react-native-renderer');
ResponderEventPlugin =
require('react-native-renderer/src/legacy-events/ResponderEventPlugin').default;
@ -77,7 +79,7 @@ beforeEach(() => {
.ReactNativeViewConfigRegistry.register;
});
it('fails to register the same event name with different types', () => {
it('fails to register the same event name with different types', async () => {
const InvalidEvents = createReactNativeComponentClass('InvalidEvents', () => {
if (!__DEV__) {
// Simulate a registration error in prod.
@ -109,15 +111,15 @@ it('fails to register the same event name with different types', () => {
// The first time this renders,
// we attempt to register the view config and fail.
expect(() => ReactNative.render(<InvalidEvents />, 1)).toThrow(
'Event cannot be both direct and bubbling: topChange',
);
await expect(
async () => await act(() => ReactNative.render(<InvalidEvents />, 1)),
).rejects.toThrow('Event cannot be both direct and bubbling: topChange');
// Continue to re-register the config and
// fail so that we don't mask the above failure.
expect(() => ReactNative.render(<InvalidEvents />, 1)).toThrow(
'Event cannot be both direct and bubbling: topChange',
);
await expect(
async () => await act(() => ReactNative.render(<InvalidEvents />, 1)),
).rejects.toThrow('Event cannot be both direct and bubbling: topChange');
});
it('fails if unknown/unsupported event types are dispatched', () => {

View File

@ -17,6 +17,7 @@ let createReactNativeComponentClass;
let UIManager;
let TextInputState;
let ReactNativePrivateInterface;
let act;
const DISPATCH_COMMAND_REQUIRES_HOST_COMPONENT =
"Warning: dispatchCommand was called with a ref that isn't a " +
@ -31,6 +32,7 @@ describe('ReactNative', () => {
jest.resetModules();
React = require('react');
act = require('internal-test-utils').act;
StrictMode = React.StrictMode;
ReactNative = require('react-native-renderer');
ReactNativePrivateInterface = require('react-native/Libraries/ReactPrivate/ReactNativePrivateInterface');
@ -476,7 +478,7 @@ describe('ReactNative', () => {
);
});
it('should throw for text not inside of a <Text> ancestor', () => {
it('should throw for text not inside of a <Text> ancestor', async () => {
const ScrollView = createReactNativeComponentClass('RCTScrollView', () => ({
validAttributes: {},
uiViewClassName: 'RCTScrollView',
@ -490,18 +492,24 @@ describe('ReactNative', () => {
uiViewClassName: 'RCTView',
}));
expect(() => ReactNative.render(<View>this should warn</View>, 11)).toThrow(
await expect(async () => {
await act(() => ReactNative.render(<View>this should warn</View>, 11));
}).rejects.toThrow(
'Text strings must be rendered within a <Text> component.',
);
expect(() =>
ReactNative.render(
<Text>
<ScrollView>hi hello hi</ScrollView>
</Text>,
11,
),
).toThrow('Text strings must be rendered within a <Text> component.');
await expect(async () => {
await act(() =>
ReactNative.render(
<Text>
<ScrollView>hi hello hi</ScrollView>
</Text>,
11,
),
);
}).rejects.toThrow(
'Text strings must be rendered within a <Text> component.',
);
});
it('should not throw for text inside of an indirect <Text> ancestor', () => {

View File

@ -14,6 +14,11 @@ import {showErrorDialog} from './ReactFiberErrorDialog';
import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
import {HostRoot} from 'react-reconciler/src/ReactWorkTags';
import reportGlobalError from 'shared/reportGlobalError';
import ReactSharedInternals from 'shared/ReactSharedInternals';
const {ReactCurrentActQueue} = ReactSharedInternals;
export function logCapturedError(
boundary: Fiber,
errorInfo: CapturedValue<mixed>,
@ -28,46 +33,72 @@ export function logCapturedError(
}
const error = (errorInfo.value: any);
if (__DEV__) {
const source = errorInfo.source;
const stack = errorInfo.stack;
const componentStack = stack !== null ? stack : '';
// TODO: There's no longer a way to silence these warnings e.g. for tests.
// See https://github.com/facebook/react/pull/13384
const componentName = source ? getComponentNameFromFiber(source) : null;
const componentNameMessage = componentName
? `The above error occurred in the <${componentName}> component:`
: 'The above error occurred in one of your React components:';
if (boundary.tag === HostRoot) {
if (__DEV__ && ReactCurrentActQueue.current !== null) {
// For uncaught errors inside act, we track them on the act and then
// rethrow them into the test.
ReactCurrentActQueue.thrownErrors.push(error);
return;
}
// For uncaught root errors we report them as uncaught to the browser's
// onerror callback. This won't have component stacks and the error addendum.
// So we add those into a separate console.warn.
reportGlobalError(error);
if (__DEV__) {
const source = errorInfo.source;
const stack = errorInfo.stack;
const componentStack = stack !== null ? stack : '';
// TODO: There's no longer a way to silence these warnings e.g. for tests.
// See https://github.com/facebook/react/pull/13384
let errorBoundaryMessage;
if (boundary.tag === HostRoot) {
errorBoundaryMessage =
const componentName = source ? getComponentNameFromFiber(source) : null;
const componentNameMessage = componentName
? `An error occurred in the <${componentName}> component:`
: 'An error occurred in one of your React components:';
console['warn'](
'%s\n%s\n\n%s',
componentNameMessage,
componentStack,
'Consider adding an error boundary to your tree to customize error handling behavior.\n' +
'Visit https://react.dev/link/error-boundaries to learn more about error boundaries.';
} else {
'Visit https://react.dev/link/error-boundaries to learn more about error boundaries.',
);
}
} else {
// Caught by error boundary
if (__DEV__) {
const source = errorInfo.source;
const stack = errorInfo.stack;
const componentStack = stack !== null ? stack : '';
// TODO: There's no longer a way to silence these warnings e.g. for tests.
// See https://github.com/facebook/react/pull/13384
const componentName = source ? getComponentNameFromFiber(source) : null;
const componentNameMessage = componentName
? `The above error occurred in the <${componentName}> component:`
: 'The above error occurred in one of your React components:';
const errorBoundaryName =
getComponentNameFromFiber(boundary) || 'Anonymous';
errorBoundaryMessage =
`React will try to recreate this component tree from scratch ` +
`using the error boundary you provided, ${errorBoundaryName}.`;
}
// In development, we provide our own message which includes the component stack
// in addition to the error.
console['error'](
// In development, we provide our own message which includes the component stack
// in addition to the error.
// Don't transform to our wrapper
'%o\n\n%s\n%s\n\n%s',
error,
componentNameMessage,
componentStack,
errorBoundaryMessage,
);
} else {
// In production, we print the error directly.
// This will include the message, the JS stack, and anything the browser wants to show.
// We pass the error object instead of custom message so that the browser displays the error natively.
console['error'](error); // Don't transform to our wrapper
console['error'](
'%o\n\n%s\n%s\n\n%s',
error,
componentNameMessage,
componentStack,
`React will try to recreate this component tree from scratch ` +
`using the error boundary you provided, ${errorBoundaryName}.`,
);
} else {
// In production, we print the error directly.
// This will include the message, the JS stack, and anything the browser wants to show.
// We pass the error object instead of custom message so that the browser displays the error natively.
console['error'](error); // Don't transform to our wrapper
}
}
} catch (e) {
// This method must not throw, or React internal state will get messed up.

View File

@ -166,7 +166,6 @@ function flushSyncWorkAcrossRoots_impl(onlyLegacy: boolean) {
// There may or may not be synchronous work scheduled. Let's check.
let didPerformSomeWork;
let errors: Array<mixed> | null = null;
isFlushingWork = true;
do {
didPerformSomeWork = false;
@ -184,48 +183,14 @@ function flushSyncWorkAcrossRoots_impl(onlyLegacy: boolean) {
);
if (includesSyncLane(nextLanes)) {
// This root has pending sync work. Flush it now.
try {
didPerformSomeWork = true;
performSyncWorkOnRoot(root, nextLanes);
} catch (error) {
// Collect errors so we can rethrow them at the end
if (errors === null) {
errors = [error];
} else {
errors.push(error);
}
}
didPerformSomeWork = true;
performSyncWorkOnRoot(root, nextLanes);
}
}
root = root.next;
}
} while (didPerformSomeWork);
isFlushingWork = false;
// If any errors were thrown, rethrow them right before exiting.
// TODO: Consider returning these to the caller, to allow them to decide
// how/when to rethrow.
if (errors !== null) {
if (errors.length > 1) {
if (typeof AggregateError === 'function') {
// eslint-disable-next-line no-undef
throw new AggregateError(errors);
} else {
for (let i = 1; i < errors.length; i++) {
scheduleImmediateTask(throwError.bind(null, errors[i]));
}
const firstError = errors[0];
throw firstError;
}
} else {
const error = errors[0];
throw error;
}
}
}
function throwError(error: mixed) {
throw error;
}
function processRootScheduleInMicrotask() {

View File

@ -59,7 +59,6 @@ import {
import {
renderDidError,
renderDidSuspendDelayIfPossible,
onUncaughtError,
markLegacyErrorBoundaryAsFailed,
isAlreadyFailedLegacyErrorBoundary,
attachPingListener,
@ -96,9 +95,7 @@ function createRootErrorUpdate(
// Caution: React DevTools currently depends on this property
// being called "element".
update.payload = {element: null};
const error = errorInfo.value;
update.callback = () => {
onUncaughtError(error);
logCapturedError(fiber, errorInfo);
};
return update;

View File

@ -277,6 +277,7 @@ import {
} from './ReactFiberRootScheduler';
import {getMaskedContext, getUnmaskedContext} from './ReactFiberContext';
import {peekEntangledActionLane} from './ReactFiberAsyncAction';
import {logCapturedError} from './ReactFiberErrorLogger';
const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
@ -348,8 +349,6 @@ export let entangledRenderLanes: Lanes = NoLanes;
// Whether to root completed, errored, suspended, etc.
let workInProgressRootExitStatus: RootExitStatus = RootInProgress;
// A fatal error, if one is thrown
let workInProgressRootFatalError: mixed = null;
// The work left over by components that were visited during this render. Only
// includes unprocessed updates, not work in bailed out children.
let workInProgressRootSkippedLanes: Lanes = NoLanes;
@ -564,8 +563,6 @@ export function getRenderTargetTime(): number {
return workInProgressRootRenderTargetTime;
}
let hasUncaughtError = false;
let firstUncaughtError = null;
let legacyErrorBoundariesThatAlreadyFailed: Set<mixed> | null = null;
let rootDoesHavePassiveEffects: boolean = false;
@ -974,11 +971,9 @@ export function performConcurrentWorkOnRoot(
}
}
if (exitStatus === RootFatalErrored) {
const fatalError = workInProgressRootFatalError;
prepareFreshStack(root, NoLanes);
markRootSuspended(root, lanes, NoLane);
ensureRootIsScheduled(root);
throw fatalError;
break;
}
// We now have a consistent tree. The next step is either to commit it,
@ -1391,11 +1386,10 @@ export function performSyncWorkOnRoot(root: FiberRoot, lanes: Lanes): null {
}
if (exitStatus === RootFatalErrored) {
const fatalError = workInProgressRootFatalError;
prepareFreshStack(root, NoLanes);
markRootSuspended(root, lanes, NoLane);
ensureRootIsScheduled(root);
throw fatalError;
return null;
}
if (exitStatus === RootDidNotComplete) {
@ -1625,7 +1619,6 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
workInProgressThrownValue = null;
workInProgressRootDidAttachPingListener = false;
workInProgressRootExitStatus = RootInProgress;
workInProgressRootFatalError = null;
workInProgressRootSkippedLanes = NoLanes;
workInProgressRootInterleavedUpdatedLanes = NoLanes;
workInProgressRootRenderPhaseUpdatedLanes = NoLanes;
@ -1738,7 +1731,10 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
if (erroredWork === null) {
// This is a fatal error
workInProgressRootExitStatus = RootFatalErrored;
workInProgressRootFatalError = thrownValue;
logCapturedError(
root.current,
createCapturedValueAtFiber(thrownValue, root.current),
);
return;
}
@ -2516,7 +2512,7 @@ function throwAndUnwindWorkLoop(
workInProgressRootRenderLanes,
);
if (didFatal) {
panicOnRootError(thrownValue);
panicOnRootError(root, thrownValue);
return;
}
} catch (error) {
@ -2528,7 +2524,7 @@ function throwAndUnwindWorkLoop(
workInProgress = returnFiber;
throw error;
} else {
panicOnRootError(thrownValue);
panicOnRootError(root, thrownValue);
return;
}
}
@ -2550,13 +2546,16 @@ function throwAndUnwindWorkLoop(
}
}
function panicOnRootError(error: mixed) {
function panicOnRootError(root: FiberRoot, error: mixed) {
// There's no ancestor that can handle this exception. This should never
// happen because the root is supposed to capture all errors that weren't
// caught by an error boundary. This is a fatal error, or panic condition,
// because we've run out of ways to recover.
workInProgressRootExitStatus = RootFatalErrored;
workInProgressRootFatalError = error;
logCapturedError(
root.current,
createCapturedValueAtFiber(error, root.current),
);
// Set `workInProgress` to null. This represents advancing to the next
// sibling, or the parent if there are no siblings. But since the root
// has no siblings nor a parent, we set it to null. Usually this is
@ -3032,13 +3031,6 @@ function commitRootImpl(
}
}
if (hasUncaughtError) {
hasUncaughtError = false;
const error = firstUncaughtError;
firstUncaughtError = null;
throw error;
}
// If the passive effects are the result of a discrete render, flush them
// synchronously at the end of the current task so that the result is
// immediately observable. Otherwise, we assume that they are not
@ -3358,14 +3350,6 @@ export function markLegacyErrorBoundaryAsFailed(instance: mixed) {
}
}
function prepareToThrowUncaughtError(error: mixed) {
if (!hasUncaughtError) {
hasUncaughtError = true;
firstUncaughtError = error;
}
}
export const onUncaughtError = prepareToThrowUncaughtError;
function captureCommitPhaseErrorOnRoot(
rootFiber: Fiber,
sourceFiber: Fiber,

View File

@ -326,10 +326,12 @@ describe('ReactFlushSync', () => {
let error;
try {
ReactDOM.flushSync(() => {
root1.render(<Throws error={aahh} />);
root2.render(<Throws error={nooo} />);
root3.render(<Text text="aww" />);
await act(() => {
ReactDOM.flushSync(() => {
root1.render(<Throws error={aahh} />);
root2.render(<Throws error={nooo} />);
root3.render(<Text text="aww" />);
});
});
} catch (e) {
error = e;

View File

@ -119,10 +119,12 @@ describe('ReactFlushSync (AggregateError not available)', () => {
overrideQueueMicrotask = true;
let error;
try {
ReactDOM.flushSync(() => {
root1.render(<Throws error={aahh} />);
root2.render(<Throws error={nooo} />);
root3.render(<Text text="aww" />);
await act(() => {
ReactDOM.flushSync(() => {
root1.render(<Throws error={aahh} />);
root2.render(<Throws error={nooo} />);
root3.render(<Text text="aww" />);
});
});
} catch (e) {
error = e;
@ -140,6 +142,6 @@ describe('ReactFlushSync (AggregateError not available)', () => {
// AggregateError is not available, React throws the first error, then
// throws the remaining errors in separate tasks.
expect(error).toBe(aahh);
expect(flushFakeMicrotasks).toThrow(nooo);
await flushFakeMicrotasks();
});
});

View File

@ -1899,7 +1899,7 @@ describe('ReactHooks', () => {
}).rejects.toThrow('Hello');
if (__DEV__) {
expect(console.error).toHaveBeenCalledTimes(2);
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error.mock.calls[0][0]).toContain(
'Warning: Cannot update a component (`%s`) while rendering ' +
'a different component (`%s`).',

View File

@ -2077,14 +2077,15 @@ describe('ReactHooksWithNoopRenderer', () => {
});
return <Text text={'Count: ' + props.count} />;
}
await act(async () => {
ReactNoop.render(<Counter count={0} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 0', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
expect(() => ReactNoop.flushPassiveEffects()).toThrow('Oops');
});
await expect(async () => {
await act(async () => {
ReactNoop.render(<Counter count={0} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 0', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
});
}).rejects.toThrow('Oops');
assertLog([
'Mount A [0]',
@ -2107,7 +2108,7 @@ describe('ReactHooksWithNoopRenderer', () => {
useEffect(() => {
if (props.count === 1) {
Scheduler.log('Oops!');
throw new Error('Oops!');
throw new Error('Oops error!');
}
Scheduler.log(`Mount B [${props.count}]`);
return () => {
@ -2126,22 +2127,27 @@ describe('ReactHooksWithNoopRenderer', () => {
assertLog(['Mount A [0]', 'Mount B [0]']);
});
await act(async () => {
// This update will trigger an error
ReactNoop.render(<Counter count={1} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 1', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
expect(() => ReactNoop.flushPassiveEffects()).toThrow('Oops');
assertLog(['Unmount A [0]', 'Unmount B [0]', 'Mount A [1]', 'Oops!']);
expect(ReactNoop).toMatchRenderedOutput(null);
});
assertLog([
// Clean up effect A runs passively on unmount.
// There's no effect B to clean-up, because it never mounted.
'Unmount A [1]',
]);
await expect(async () => {
await act(async () => {
// This update will trigger an error
ReactNoop.render(<Counter count={1} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 1', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
ReactNoop.flushPassiveEffects();
assertLog([
'Unmount A [0]',
'Unmount B [0]',
'Mount A [1]',
'Oops!',
// Clean up effect A runs passively on unmount.
// There's no effect B to clean-up, because it never mounted.
'Unmount A [1]',
]);
expect(ReactNoop).toMatchRenderedOutput(null);
});
}).rejects.toThrow('Oops error!');
});
it('handles errors in destroy on update', async () => {
@ -2151,7 +2157,7 @@ describe('ReactHooksWithNoopRenderer', () => {
return () => {
Scheduler.log('Oops!');
if (props.count === 0) {
throw new Error('Oops!');
throw new Error('Oops error!');
}
};
});
@ -2174,26 +2180,34 @@ describe('ReactHooksWithNoopRenderer', () => {
assertLog(['Mount A [0]', 'Mount B [0]']);
});
await act(async () => {
// This update will trigger an error during passive effect unmount
ReactNoop.render(<Counter count={1} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 1', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
expect(() => ReactNoop.flushPassiveEffects()).toThrow('Oops');
await expect(async () => {
await act(async () => {
// This update will trigger an error during passive effect unmount
ReactNoop.render(<Counter count={1} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 1', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
ReactNoop.flushPassiveEffects();
// This branch enables a feature flag that flushes all passive destroys in a
// separate pass before flushing any passive creates.
// A result of this two-pass flush is that an error thrown from unmount does
// not block the subsequent create functions from being run.
assertLog(['Oops!', 'Unmount B [0]', 'Mount A [1]', 'Mount B [1]']);
});
// This branch enables a feature flag that flushes all passive destroys in a
// separate pass before flushing any passive creates.
// A result of this two-pass flush is that an error thrown from unmount does
// not block the subsequent create functions from being run.
assertLog([
'Oops!',
'Unmount B [0]',
'Mount A [1]',
'Mount B [1]',
// <Counter> gets unmounted because an error is thrown above.
// The remaining destroy functions are run later on unmount, since they're passive.
// In this case, one of them throws again (because of how the test is written).
'Oops!',
'Unmount B [1]',
]);
});
}).rejects.toThrow('Oops error!');
// <Counter> gets unmounted because an error is thrown above.
// The remaining destroy functions are run later on unmount, since they're passive.
// In this case, one of them throws again (because of how the test is written).
assertLog(['Oops!', 'Unmount B [1]']);
expect(ReactNoop).toMatchRenderedOutput(null);
});
@ -3805,7 +3819,7 @@ describe('ReactHooksWithNoopRenderer', () => {
await waitForThrow(
'Rendered more hooks than during the previous render.',
);
assertLog([]);
assertLog(['Unmount A']);
}).toErrorDev([
'Warning: React has detected a change in the order of Hooks called by App. ' +
'This will lead to bugs and errors if not fixed. For more information, ' +

View File

@ -387,7 +387,7 @@ describe('ReactIncrementalErrorHandling', () => {
// The work loop unwound to the nearest error boundary. React will try
// to render one more time, synchronously. Flush just one unit of work to
// demonstrate that this render is synchronous.
expect(() => Scheduler.unstable_flushNumberOfYields(1)).toThrow('oops');
Scheduler.unstable_flushNumberOfYields(1);
assertLog(['Parent', 'BadRender', 'commit']);
expect(ReactNoop).toMatchRenderedOutput(null);
});
@ -425,10 +425,8 @@ describe('ReactIncrementalErrorHandling', () => {
// Expire the render midway through
Scheduler.unstable_advanceTime(10000);
expect(() => {
Scheduler.unstable_flushExpired();
ReactNoop.flushSync();
}).toThrow('Oops');
Scheduler.unstable_flushExpired();
ReactNoop.flushSync();
assertLog([
// The render expired, but we shouldn't throw out the partial work.
@ -769,15 +767,14 @@ describe('ReactIncrementalErrorHandling', () => {
throw new Error('Hello');
}
expect(() => {
ReactNoop.flushSync(() => {
ReactNoop.render(
<RethrowErrorBoundary>
<BrokenRender />
</RethrowErrorBoundary>,
);
});
}).toThrow('Hello');
ReactNoop.flushSync(() => {
ReactNoop.render(
<RethrowErrorBoundary>
<BrokenRender />
</RethrowErrorBoundary>,
);
});
assertLog([
'RethrowErrorBoundary render',
'BrokenRender',
@ -809,18 +806,17 @@ describe('ReactIncrementalErrorHandling', () => {
throw new Error('Hello');
}
expect(() => {
ReactNoop.flushSync(() => {
ReactNoop.render(
<RethrowErrorBoundary>Before the storm.</RethrowErrorBoundary>,
);
ReactNoop.render(
<RethrowErrorBoundary>
<BrokenRender />
</RethrowErrorBoundary>,
);
});
}).toThrow('Hello');
ReactNoop.flushSync(() => {
ReactNoop.render(
<RethrowErrorBoundary>Before the storm.</RethrowErrorBoundary>,
);
ReactNoop.render(
<RethrowErrorBoundary>
<BrokenRender />
</RethrowErrorBoundary>,
);
});
assertLog([
'RethrowErrorBoundary render',
'BrokenRender',
@ -1120,14 +1116,15 @@ describe('ReactIncrementalErrorHandling', () => {
expect(ReactNoop.getChildrenAsJSX('e')).toEqual(null);
ReactNoop.renderToRootWithID(<BrokenRender label="a" />, 'a');
await waitForThrow('a');
ReactNoop.renderToRootWithID(<span prop="b:6" />, 'b');
ReactNoop.renderToRootWithID(<BrokenRender label="c" />, 'c');
await waitForThrow('c');
ReactNoop.renderToRootWithID(<span prop="d:6" />, 'd');
ReactNoop.renderToRootWithID(<BrokenRender label="e" />, 'e');
ReactNoop.renderToRootWithID(<span prop="f:6" />, 'f');
await waitForThrow('a');
await waitForThrow('c');
await waitForThrow('e');
await waitForAll([]);
@ -1369,8 +1366,10 @@ describe('ReactIncrementalErrorHandling', () => {
let aggregateError;
try {
ReactNoop.flushSync(() => {
inst.setState({fail: true});
await act(() => {
ReactNoop.flushSync(() => {
inst.setState({fail: true});
});
});
} catch (e) {
aggregateError = e;
@ -1387,9 +1386,10 @@ describe('ReactIncrementalErrorHandling', () => {
// React threw both errors as a single AggregateError
const errors = aggregateError.errors;
expect(errors.length).toBe(2);
expect(errors.length).toBe(3);
expect(errors[0].message).toBe('Hello.');
expect(errors[1].message).toBe('One does not simply unmount me.');
expect(errors[2].message).toBe('One does not simply unmount me.');
});
it('does not interrupt unmounting if detaching a ref throws', async () => {
@ -1878,6 +1878,7 @@ describe('ReactIncrementalErrorHandling', () => {
// accident) a render phase triggered from userspace.
spyOnDev(console, 'error').mockImplementation(() => {});
spyOnDev(console, 'warn').mockImplementation(() => {});
let numberOfThrows = 0;
@ -1916,12 +1917,13 @@ describe('ReactIncrementalErrorHandling', () => {
expect(numberOfThrows < 100).toBe(true);
if (__DEV__) {
expect(console.error).toHaveBeenCalledTimes(2);
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error.mock.calls[0][0]).toContain(
'Cannot update a component (`%s`) while rendering a different component',
);
expect(console.error.mock.calls[1][2]).toContain(
'The above error occurred in the <App> component',
expect(console.warn).toHaveBeenCalledTimes(1);
expect(console.warn.mock.calls[0][1]).toContain(
'An error occurred in the <App> component',
);
}
});

View File

@ -14,7 +14,13 @@ let React;
let ReactNoop;
let Scheduler;
let waitForAll;
let waitForThrow;
let uncaughtExceptionMock;
async function fakeAct(cb) {
// We don't use act/waitForThrow here because we want to observe how errors are reported for real.
await cb();
Scheduler.unstable_flushAll();
}
describe('ReactIncrementalErrorLogging', () => {
beforeEach(() => {
@ -25,20 +31,28 @@ describe('ReactIncrementalErrorLogging', () => {
const InternalTestUtils = require('internal-test-utils');
waitForAll = InternalTestUtils.waitForAll;
waitForThrow = InternalTestUtils.waitForThrow;
});
// Note: in this test file we won't be using toErrorDev() matchers
// because they filter out precisely the messages we want to test for.
let oldConsoleWarn;
let oldConsoleError;
beforeEach(() => {
oldConsoleWarn = console.warn;
oldConsoleError = console.error;
console.warn = jest.fn();
console.error = jest.fn();
uncaughtExceptionMock = jest.fn();
process.on('uncaughtException', uncaughtExceptionMock);
});
afterEach(() => {
console.warn = oldConsoleWarn;
console.error = oldConsoleError;
process.off('uncaughtException', uncaughtExceptionMock);
oldConsoleWarn = null;
oldConsoleError = null;
uncaughtExceptionMock = null;
});
it('should log errors that occur during the begin phase', async () => {
@ -51,23 +65,27 @@ describe('ReactIncrementalErrorLogging', () => {
return <div />;
}
}
ReactNoop.render(
<div>
<span>
<ErrorThrowingComponent />
</span>
</div>,
await fakeAct(() => {
ReactNoop.render(
<div>
<span>
<ErrorThrowingComponent />
</span>
</div>,
);
});
expect(uncaughtExceptionMock).toHaveBeenCalledTimes(1);
expect(uncaughtExceptionMock).toHaveBeenCalledWith(
expect.objectContaining({
message: 'constructor error',
}),
);
await waitForThrow('constructor error');
expect(console.error).toHaveBeenCalledTimes(1);
if (__DEV__) {
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('%o'),
expect.objectContaining({
message: 'constructor error',
}),
expect(console.warn).toHaveBeenCalledTimes(1);
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining('%s'),
expect.stringContaining(
'The above error occurred in the <ErrorThrowingComponent> component:',
'An error occurred in the <ErrorThrowingComponent> component:',
),
expect.stringMatching(
new RegExp(
@ -81,12 +99,6 @@ describe('ReactIncrementalErrorLogging', () => {
'to customize error handling behavior.',
),
);
} else {
expect(console.error).toHaveBeenCalledWith(
expect.objectContaining({
message: 'constructor error',
}),
);
}
});
@ -99,23 +111,27 @@ describe('ReactIncrementalErrorLogging', () => {
return <div />;
}
}
ReactNoop.render(
<div>
<span>
<ErrorThrowingComponent />
</span>
</div>,
await fakeAct(() => {
ReactNoop.render(
<div>
<span>
<ErrorThrowingComponent />
</span>
</div>,
);
});
expect(uncaughtExceptionMock).toHaveBeenCalledTimes(1);
expect(uncaughtExceptionMock).toHaveBeenCalledWith(
expect.objectContaining({
message: 'componentDidMount error',
}),
);
await waitForThrow('componentDidMount error');
expect(console.error).toHaveBeenCalledTimes(1);
if (__DEV__) {
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('%o'),
expect.objectContaining({
message: 'componentDidMount error',
}),
expect(console.warn).toHaveBeenCalledTimes(1);
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining('%s'),
expect.stringContaining(
'The above error occurred in the <ErrorThrowingComponent> component:',
'An error occurred in the <ErrorThrowingComponent> component:',
),
expect.stringMatching(
new RegExp(
@ -129,12 +145,6 @@ describe('ReactIncrementalErrorLogging', () => {
'to customize error handling behavior.',
),
);
} else {
expect(console.error).toHaveBeenCalledWith(
expect.objectContaining({
message: 'componentDidMount error',
}),
);
}
});
@ -145,19 +155,32 @@ describe('ReactIncrementalErrorLogging', () => {
logCapturedErrorCalls.push(error);
throw new Error('logCapturedError error');
});
class ErrorBoundary extends React.Component {
state = {error: null};
componentDidCatch(error) {
this.setState({error});
}
render() {
return this.state.error ? null : this.props.children;
}
}
class ErrorThrowingComponent extends React.Component {
render() {
throw new Error('render error');
}
}
ReactNoop.render(
<div>
<span>
<ErrorThrowingComponent />
</span>
</div>,
);
await waitForThrow('render error');
await fakeAct(() => {
ReactNoop.render(
<div>
<ErrorBoundary>
<span>
<ErrorThrowingComponent />
</span>
</ErrorBoundary>
</div>,
);
});
expect(logCapturedErrorCalls.length).toBe(1);
if (__DEV__) {
expect(console.error).toHaveBeenCalledWith(
@ -172,12 +195,13 @@ describe('ReactIncrementalErrorLogging', () => {
new RegExp(
'\\s+(in|at) ErrorThrowingComponent (.*)\n' +
'\\s+(in|at) span(.*)\n' +
'\\s+(in|at) ErrorBoundary(.*)\n' +
'\\s+(in|at) div(.*)',
),
),
expect.stringContaining(
'Consider adding an error boundary to your tree ' +
'to customize error handling behavior.',
'React will try to recreate this component tree from scratch ' +
'using the error boundary you provided, ErrorBoundary.',
),
);
} else {

View File

@ -230,7 +230,7 @@ describe('ReactLazy', () => {
assertLog(['Loading...']);
expect(root).not.toMatchRenderedOutput('Hi');
if (__DEV__) {
expect(console.error).toHaveBeenCalledTimes(3);
expect(console.error).toHaveBeenCalledTimes(2);
expect(console.error.mock.calls[0][0]).toContain(
'Expected the result of a dynamic import() call',
);

View File

@ -887,9 +887,11 @@ describe('ReactSuspenseWithNoopRenderer', () => {
});
// @gate enableLegacyCache
it('in legacy mode, errors when an update suspends without a Suspense boundary during a sync update', () => {
it('in legacy mode, errors when an update suspends without a Suspense boundary during a sync update', async () => {
const root = ReactNoop.createLegacyRoot();
expect(() => root.render(<AsyncText text="Async" />)).toThrow(
await expect(async () => {
await act(() => root.render(<AsyncText text="Async" />));
}).rejects.toThrow(
'A component suspended while responding to synchronous input.',
);
});

View File

@ -71,7 +71,15 @@ describe('ReactFresh', () => {
return Component;
}
function patch(version) {
async function patch(version) {
const Component = version();
await act(() => {
ReactFreshRuntime.performReactRefresh();
});
return Component;
}
function patchSync(version) {
const Component = version();
ReactFreshRuntime.performReactRefresh();
return Component;
@ -124,7 +132,7 @@ describe('ReactFresh', () => {
expect(el.textContent).toBe('1');
// Perform a hot update.
const HelloV2 = patch(() => {
const HelloV2 = await patch(() => {
function Hello() {
const [val, setVal] = React.useState(0);
return (
@ -217,7 +225,7 @@ describe('ReactFresh', () => {
expect(el.textContent).toBe('1');
// Perform a hot update.
const OuterV2 = patch(() => {
const OuterV2 = await patch(() => {
function Hello() {
const [val, setVal] = React.useState(0);
return (
@ -348,7 +356,7 @@ describe('ReactFresh', () => {
expect(el.textContent).toBe('1');
// Patch to change the color.
const ParentV2 = patch(() => {
const ParentV2 = await patch(() => {
function Hello() {
const [val, setVal] = React.useState(0);
return (
@ -435,7 +443,7 @@ describe('ReactFresh', () => {
expect(el.textContent).toBe('1');
// Perform a hot update.
patch(() => {
await patch(() => {
function Hello({color}) {
const [val, setVal] = React.useState(0);
return (
@ -489,7 +497,7 @@ describe('ReactFresh', () => {
expect(el.textContent).toBe('1');
// Perform a hot update of just the rendering function.
patch(() => {
await patch(() => {
function Hello({color}) {
const [val, setVal] = React.useState(0);
return (
@ -543,7 +551,7 @@ describe('ReactFresh', () => {
expect(el.textContent).toBe('1');
// Perform a hot update.
const OuterV2 = patch(() => {
const OuterV2 = await patch(() => {
function Hello() {
const [val, setVal] = React.useState(0);
return (
@ -632,7 +640,7 @@ describe('ReactFresh', () => {
expect(el.textContent).toBe('1');
// Perform a hot update.
const OuterV2 = patch(() => {
const OuterV2 = await patch(() => {
function Hello() {
const [val, setVal] = React.useState(0);
return (
@ -719,7 +727,7 @@ describe('ReactFresh', () => {
expect(el.textContent).toBe('1');
// Perform a hot update of just the rendering function.
patch(() => {
await patch(() => {
function Hello() {
const [val, setVal] = React.useState(0);
return (
@ -768,7 +776,7 @@ describe('ReactFresh', () => {
expect(el.textContent).toBe('1');
// Perform a hot update.
const OuterV2 = patch(() => {
const OuterV2 = await patch(() => {
function Hello() {
const [val, setVal] = React.useState(0);
return (
@ -880,7 +888,7 @@ describe('ReactFresh', () => {
expect(el.textContent).toBe('1');
// Perform a hot update.
const AppV2 = patch(() => {
const AppV2 = await patch(() => {
function Hello() {
const [val, setVal] = React.useState(0);
return (
@ -1003,7 +1011,7 @@ describe('ReactFresh', () => {
expect(container.textContent).toBe('Loading');
// Perform a hot update.
patch(() => {
await patch(() => {
function Hello() {
const [val, setVal] = React.useState(0);
return (
@ -1033,7 +1041,7 @@ describe('ReactFresh', () => {
expect(el.style.color).toBe('red');
// Test another reload.
patch(() => {
await patch(() => {
function Hello() {
const [val, setVal] = React.useState(0);
return (
@ -1087,7 +1095,7 @@ describe('ReactFresh', () => {
expect(container.textContent).toBe('Loading');
// Perform a hot update.
patch(() => {
await patch(() => {
function renderHello() {
const [val, setVal] = React.useState(0);
return (
@ -1118,7 +1126,7 @@ describe('ReactFresh', () => {
expect(el.style.color).toBe('red');
// Test another reload.
patch(() => {
await patch(() => {
function renderHello() {
const [val, setVal] = React.useState(0);
return (
@ -1173,7 +1181,7 @@ describe('ReactFresh', () => {
expect(container.textContent).toBe('Loading');
// Perform a hot update.
patch(() => {
await patch(() => {
function renderHello() {
const [val, setVal] = React.useState(0);
return (
@ -1204,7 +1212,7 @@ describe('ReactFresh', () => {
expect(el.style.color).toBe('red');
// Test another reload.
patch(() => {
await patch(() => {
function renderHello() {
const [val, setVal] = React.useState(0);
return (
@ -1259,7 +1267,7 @@ describe('ReactFresh', () => {
expect(container.textContent).toBe('Loading');
// Perform a hot update.
patch(() => {
await patch(() => {
function renderHello() {
const [val, setVal] = React.useState(0);
return (
@ -1290,7 +1298,7 @@ describe('ReactFresh', () => {
expect(el.style.color).toBe('red');
// Test another reload.
patch(() => {
await patch(() => {
function renderHello() {
const [val, setVal] = React.useState(0);
return (
@ -1358,7 +1366,7 @@ describe('ReactFresh', () => {
expect(primaryChild.style.display).toBe('');
// Perform a hot update.
patch(() => {
await patch(() => {
function Hello({children}) {
const [val, setVal] = React.useState(0);
return (
@ -1404,7 +1412,7 @@ describe('ReactFresh', () => {
expect(fallbackChild.style.display).toBe('');
// Perform a hot update.
patch(() => {
await patch(() => {
function Hello({children}) {
const [val, setVal] = React.useState(0);
return (
@ -1436,7 +1444,7 @@ describe('ReactFresh', () => {
expect(primaryChild.style.display).toBe('');
// Perform a hot update.
patch(() => {
await patch(() => {
function Hello({children}) {
const [val, setVal] = React.useState(0);
return (
@ -1492,7 +1500,7 @@ describe('ReactFresh', () => {
expect(appRenders).toBe(1);
// Perform a hot update for Hello only.
patch(() => {
await patch(() => {
function Hello() {
const [val, setVal] = React.useState(0);
return (
@ -1552,7 +1560,7 @@ describe('ReactFresh', () => {
expect(container.textContent).toBe('XXXXXXXXXX');
helloRenders = 0;
patch(() => {
await patch(() => {
function Hello({children}) {
helloRenders++;
return <div>O{children}O</div>;
@ -1619,7 +1627,7 @@ describe('ReactFresh', () => {
expect(el2.textContent).toBe('1');
// Perform a hot update for both inner components.
patch(() => {
await patch(() => {
function Hello1() {
const [val, setVal] = React.useState(0);
return (
@ -1681,7 +1689,7 @@ describe('ReactFresh', () => {
expect(el.textContent).toBe('1');
// Perform a hot update.
const HelloV2 = patch(() => {
const HelloV2 = await patch(() => {
function Hello() {
const [val, setVal] = React.useState(0);
return (
@ -1702,7 +1710,7 @@ describe('ReactFresh', () => {
expect(el.style.color).toBe('red');
// Perform a hot update.
const HelloV3 = patch(() => {
const HelloV3 = await patch(() => {
function Hello() {
const [val, setVal] = React.useState(0);
return (
@ -1743,7 +1751,7 @@ describe('ReactFresh', () => {
expect(newEl.style.color).toBe('yellow');
// Verify we can patch again while preserving the signature.
patch(() => {
await patch(() => {
function Hello() {
const [val, setVal] = React.useState(0);
return (
@ -1763,7 +1771,7 @@ describe('ReactFresh', () => {
expect(newEl.style.color).toBe('purple');
// Check removing the signature also causes a remount.
patch(() => {
await patch(() => {
function Hello() {
const [val, setVal] = React.useState(0);
return (
@ -1940,7 +1948,7 @@ describe('ReactFresh', () => {
}, 10000);
async function runRemountingStressTest(tree) {
patch(() => {
await patch(() => {
function Hello({children}) {
return <section data-color="blue">{children}</section>;
}
@ -1961,7 +1969,7 @@ describe('ReactFresh', () => {
});
// Patch color without changing the signature.
patch(() => {
await patch(() => {
function Hello({children}) {
return <section data-color="red">{children}</section>;
}
@ -1980,7 +1988,7 @@ describe('ReactFresh', () => {
});
// Patch color *and* change the signature.
patch(() => {
await patch(() => {
function Hello({children}) {
return <section data-color="orange">{children}</section>;
}
@ -1999,7 +2007,7 @@ describe('ReactFresh', () => {
});
// Now patch color but *don't* change the signature.
patch(() => {
await patch(() => {
function Hello({children}) {
return <section data-color="black">{children}</section>;
}
@ -2229,7 +2237,7 @@ describe('ReactFresh', () => {
expect(el.textContent).toBe('1');
// Perform a hot update that doesn't remount.
patch(() => {
await patch(() => {
function Hello() {
const [val, setVal] = React.useState(0);
return (
@ -2250,7 +2258,7 @@ describe('ReactFresh', () => {
expect(el.style.color).toBe('red');
// Perform a hot update that remounts.
patch(() => {
await patch(() => {
function Hello() {
const [val, setVal] = React.useState(0);
return (
@ -2279,7 +2287,7 @@ describe('ReactFresh', () => {
expect(newEl.style.color).toBe('yellow');
// Verify we can patch again while preserving the signature.
patch(() => {
await patch(() => {
function Hello() {
const [val, setVal] = React.useState(0);
return (
@ -2299,7 +2307,7 @@ describe('ReactFresh', () => {
expect(newEl.style.color).toBe('purple');
// Check removing the signature also causes a remount.
patch(() => {
await patch(() => {
function Hello() {
const [val, setVal] = React.useState(0);
return (
@ -2356,7 +2364,7 @@ describe('ReactFresh', () => {
expect(useEffectWithEmptyArrayCalls).toBe(1); // useEffect didn't re-run
// Perform a hot update.
patch(() => {
await patch(() => {
function Hello() {
const [val, setVal] = React.useState(0);
const tranformed = React.useMemo(() => val * 10, [val]);
@ -2413,7 +2421,7 @@ describe('ReactFresh', () => {
expect(el.style.color).toBe('blue');
// Perform a hot update.
patch(() => {
await patch(() => {
function Hello() {
const source = React.useMemo(() => ({value: 20}), []);
const [state, setState] = React.useState({value: null});
@ -2468,7 +2476,7 @@ describe('ReactFresh', () => {
expect(el.firstChild).toBe(null); // Offscreen content not flushed yet.
// Perform a hot update.
patch(() => {
patchSync(() => {
function Hello() {
React.useLayoutEffect(() => {
Scheduler.log('Hello#layout');
@ -2507,7 +2515,7 @@ describe('ReactFresh', () => {
expect(el.firstChild.style.color).toBe('red');
// Hot reload while we're offscreen.
patch(() => {
patchSync(() => {
function Hello() {
React.useLayoutEffect(() => {
Scheduler.log('Hello#layout');
@ -2575,7 +2583,7 @@ describe('ReactFresh', () => {
const secondP = firstP.nextSibling.nextSibling;
// Perform a hot update that fails.
patch(() => {
await patch(() => {
function Hello() {
throw new Error('No');
}
@ -2587,7 +2595,7 @@ describe('ReactFresh', () => {
expect(container.firstChild.nextSibling.nextSibling).toBe(secondP);
// Perform a hot update that fixes the error.
patch(() => {
await patch(() => {
function Hello() {
return <h1>Fixed!</h1>;
}
@ -2601,7 +2609,7 @@ describe('ReactFresh', () => {
// Verify next hot reload doesn't remount anything.
const helloNode = container.firstChild.nextSibling;
patch(() => {
await patch(() => {
function Hello() {
return <h1>Nice.</h1>;
}
@ -2653,7 +2661,7 @@ describe('ReactFresh', () => {
const secondP = firstP.nextSibling.nextSibling;
// Perform a hot update that fails.
patch(() => {
await patch(() => {
function Hello() {
throw new Error('No');
}
@ -2665,7 +2673,7 @@ describe('ReactFresh', () => {
expect(container.firstChild.nextSibling.nextSibling).toBe(secondP);
// Perform a hot update that fixes the error.
patch(() => {
await patch(() => {
function Hello() {
return <h1>Fixed!</h1>;
}
@ -2679,7 +2687,7 @@ describe('ReactFresh', () => {
// Verify next hot reload doesn't remount anything.
const helloNode = container.firstChild.nextSibling;
patch(() => {
await patch(() => {
function Hello() {
return <h1>Nice.</h1>;
}
@ -2735,7 +2743,7 @@ describe('ReactFresh', () => {
// Perform a hot update that fails.
let crash;
patch(() => {
await patch(() => {
function Hello() {
const [x, setX] = React.useState('');
React.useEffect(() => {
@ -2761,7 +2769,7 @@ describe('ReactFresh', () => {
expect(container.firstChild.nextSibling.nextSibling).toBe(secondP);
// Perform a hot update that fixes the error.
patch(() => {
await patch(() => {
function Hello() {
const [x] = React.useState('');
React.useEffect(() => {}, []); // Removes the bad effect code.
@ -2778,7 +2786,7 @@ describe('ReactFresh', () => {
// Verify next hot reload doesn't remount anything.
const helloNode = container.firstChild.nextSibling;
patch(() => {
await patch(() => {
function Hello() {
const [x] = React.useState('');
React.useEffect(() => {}, []);
@ -2808,18 +2816,18 @@ describe('ReactFresh', () => {
expect(container.innerHTML).toBe('');
// A bad retry
expect(() => {
patch(() => {
await expect(async () => {
await patch(() => {
function Hello() {
throw new Error('Not yet');
}
$RefreshReg$(Hello, 'Hello');
});
}).toThrow('Not yet');
}).rejects.toThrow('Not yet');
expect(container.innerHTML).toBe('');
// Perform a hot update that fixes the error.
patch(() => {
await patch(() => {
function Hello() {
return <h1>Fixed!</h1>;
}
@ -2829,25 +2837,25 @@ describe('ReactFresh', () => {
expect(container.innerHTML).toBe('<h1>Fixed!</h1>');
// Ensure we can keep failing and recovering later.
expect(() => {
patch(() => {
await expect(async () => {
await patch(() => {
function Hello() {
throw new Error('No 2');
}
$RefreshReg$(Hello, 'Hello');
});
}).toThrow('No 2');
}).rejects.toThrow('No 2');
expect(container.innerHTML).toBe('');
expect(() => {
patch(() => {
await expect(async () => {
await patch(() => {
function Hello() {
throw new Error('Not yet 2');
}
$RefreshReg$(Hello, 'Hello');
});
}).toThrow('Not yet 2');
}).rejects.toThrow('Not yet 2');
expect(container.innerHTML).toBe('');
patch(() => {
await patch(() => {
function Hello() {
return <h1>Fixed 2!</h1>;
}
@ -2859,14 +2867,14 @@ describe('ReactFresh', () => {
await act(() => {
root.unmount();
});
patch(() => {
await patch(() => {
function Hello() {
throw new Error('Ignored');
}
$RefreshReg$(Hello, 'Hello');
});
expect(container.innerHTML).toBe('');
patch(() => {
await patch(() => {
function Hello() {
return <h1>Ignored</h1>;
}
@ -2896,7 +2904,7 @@ describe('ReactFresh', () => {
});
// Perform a hot update that fixes the error.
patch(() => {
await patch(() => {
function Hello() {
return <h1>Fixed!</h1>;
}
@ -2921,29 +2929,29 @@ describe('ReactFresh', () => {
// Perform a hot update that fails.
// This removes the root.
expect(() => {
patch(() => {
await expect(async () => {
await patch(() => {
function Hello() {
throw new Error('No');
}
$RefreshReg$(Hello, 'Hello');
});
}).toThrow('No');
}).rejects.toThrow('No');
expect(container.innerHTML).toBe('');
// A bad retry
expect(() => {
patch(() => {
await expect(async () => {
await patch(() => {
function Hello() {
throw new Error('Not yet');
}
$RefreshReg$(Hello, 'Hello');
});
}).toThrow('Not yet');
}).rejects.toThrow('Not yet');
expect(container.innerHTML).toBe('');
// Perform a hot update that fixes the error.
patch(() => {
await patch(() => {
function Hello() {
return <h1>Fixed!</h1>;
}
@ -2954,7 +2962,7 @@ describe('ReactFresh', () => {
// Verify next hot reload doesn't remount anything.
const helloNode = container.firstChild;
patch(() => {
await patch(() => {
function Hello() {
return <h1>Nice.</h1>;
}
@ -2964,18 +2972,18 @@ describe('ReactFresh', () => {
expect(helloNode.textContent).toBe('Nice.');
// Break again.
expect(() => {
patch(() => {
await expect(async () => {
await patch(() => {
function Hello() {
throw new Error('Oops');
}
$RefreshReg$(Hello, 'Hello');
});
}).toThrow('Oops');
}).rejects.toThrow('Oops');
expect(container.innerHTML).toBe('');
// Perform a hot update that fixes the error.
patch(() => {
await patch(() => {
function Hello() {
return <h1>At last.</h1>;
}
@ -2989,7 +2997,7 @@ describe('ReactFresh', () => {
root.unmount();
});
expect(container.innerHTML).toBe('');
patch(() => {
await patch(() => {
function Hello() {
return <h1>Never mind me!</h1>;
}
@ -3010,14 +3018,14 @@ describe('ReactFresh', () => {
expect(container.innerHTML).toBe('<h1>Hi</h1>');
// Break again.
expect(() => {
patch(() => {
await expect(async () => {
await patch(() => {
function Hello() {
throw new Error('Oops');
}
$RefreshReg$(Hello, 'Hello');
});
}).toThrow('Oops');
}).rejects.toThrow('Oops');
expect(container.innerHTML).toBe('');
// Check we don't attempt to reverse an intentional unmount, even after an error.
@ -3025,7 +3033,7 @@ describe('ReactFresh', () => {
root.unmount();
});
expect(container.innerHTML).toBe('');
patch(() => {
await patch(() => {
function Hello() {
return <h1>Never mind me!</h1>;
}
@ -3139,7 +3147,7 @@ describe('ReactFresh', () => {
expect(el.textContent).toBe('1');
// Perform a hot update.
const HelloV2 = patch(() => {
const HelloV2 = await patch(() => {
class Hello extends React.Component {
state = {count: 0};
handleClick = () => {
@ -3176,7 +3184,7 @@ describe('ReactFresh', () => {
expect(newEl.style.color).toBe('red');
expect(newEl.textContent).toBe('1');
const HelloV3 = patch(() => {
const HelloV3 = await patch(() => {
class Hello extends React.Component {
state = {count: 0};
handleClick = () => {
@ -3235,7 +3243,7 @@ describe('ReactFresh', () => {
);
expect(testRef.current.getColor()).toBe('green');
patch(() => {
await patch(() => {
class Hello extends React.Component {
getColor() {
return 'orange';
@ -3248,7 +3256,7 @@ describe('ReactFresh', () => {
});
expect(testRef.current.getColor()).toBe('orange');
patch(() => {
await patch(() => {
const Hello = React.forwardRef((props, ref) => {
React.useImperativeHandle(ref, () => ({
getColor() {
@ -3261,7 +3269,7 @@ describe('ReactFresh', () => {
});
expect(testRef.current.getColor()).toBe('pink');
patch(() => {
await patch(() => {
const Hello = React.forwardRef((props, ref) => {
React.useImperativeHandle(ref, () => ({
getColor() {
@ -3274,7 +3282,7 @@ describe('ReactFresh', () => {
});
expect(testRef.current.getColor()).toBe('yellow');
patch(() => {
await patch(() => {
const Hello = React.forwardRef((props, ref) => {
React.useImperativeHandle(ref, () => ({
getColor() {
@ -3314,7 +3322,7 @@ describe('ReactFresh', () => {
expect(el.textContent).toBe('1');
// Perform a hot update that turns it into a class.
const HelloV2 = patch(() => {
const HelloV2 = await patch(() => {
class Hello extends React.Component {
state = {count: 0};
handleClick = () => {
@ -3352,7 +3360,7 @@ describe('ReactFresh', () => {
expect(newEl.textContent).toBe('1');
// Now convert it back to a function.
const HelloV3 = patch(() => {
const HelloV3 = await patch(() => {
function Hello() {
const [val, setVal] = React.useState(0);
return (
@ -3383,7 +3391,7 @@ describe('ReactFresh', () => {
expect(finalEl.textContent).toBe('1');
// Now that it's a function, verify edits keep state.
patch(() => {
await patch(() => {
function Hello() {
const [val, setVal] = React.useState(0);
return (
@ -3872,7 +3880,7 @@ describe('ReactFresh', () => {
expect(el.textContent).toBe('1');
// Perform a hot update.
patch(() => {
await patch(() => {
function Hello() {
const [val, setVal] = React.useState(0);
return (

View File

@ -19,6 +19,14 @@ let actScopeDepth = 0;
// We only warn the first time you neglect to await an async `act` scope.
let didWarnNoAwaitAct = false;
function aggregateErrors(errors: Array<mixed>): mixed {
if (errors.length > 1 && typeof AggregateError === 'function') {
// eslint-disable-next-line no-undef
return new AggregateError(errors);
}
return errors[0];
}
export function act<T>(callback: () => T | Thenable<T>): Thenable<T> {
if (__DEV__) {
// When ReactCurrentActQueue.current is not null, it signals to React that
@ -71,9 +79,14 @@ export function act<T>(callback: () => T | Thenable<T>): Thenable<T> {
// one used to track `act` scopes. Why, you may be wondering? Because
// that's how it worked before version 18. Yes, it's confusing! We should
// delete legacy mode!!
ReactCurrentActQueue.thrownErrors.push(error);
}
if (ReactCurrentActQueue.thrownErrors.length > 0) {
ReactCurrentActQueue.isBatchingLegacy = prevIsBatchingLegacy;
popActScope(prevActQueue, prevActScopeDepth);
throw error;
const thrownError = aggregateErrors(ReactCurrentActQueue.thrownErrors);
ReactCurrentActQueue.thrownErrors.length = 0;
throw thrownError;
}
if (
@ -123,7 +136,14 @@ export function act<T>(callback: () => T | Thenable<T>): Thenable<T> {
// `thenable` might not be a real promise, and `flushActQueue`
// might throw, so we need to wrap `flushActQueue` in a
// try/catch.
reject(error);
ReactCurrentActQueue.thrownErrors.push(error);
}
if (ReactCurrentActQueue.thrownErrors.length > 0) {
const thrownError = aggregateErrors(
ReactCurrentActQueue.thrownErrors,
);
ReactCurrentActQueue.thrownErrors.length = 0;
reject(thrownError);
}
} else {
resolve(returnValue);
@ -131,7 +151,15 @@ export function act<T>(callback: () => T | Thenable<T>): Thenable<T> {
},
error => {
popActScope(prevActQueue, prevActScopeDepth);
reject(error);
if (ReactCurrentActQueue.thrownErrors.length > 0) {
const thrownError = aggregateErrors(
ReactCurrentActQueue.thrownErrors,
);
ReactCurrentActQueue.thrownErrors.length = 0;
reject(thrownError);
} else {
reject(error);
}
},
);
},
@ -183,6 +211,13 @@ export function act<T>(callback: () => T | Thenable<T>): Thenable<T> {
// to be awaited, regardless of whether the callback is sync or async.
ReactCurrentActQueue.current = null;
}
if (ReactCurrentActQueue.thrownErrors.length > 0) {
const thrownError = aggregateErrors(ReactCurrentActQueue.thrownErrors);
ReactCurrentActQueue.thrownErrors.length = 0;
throw thrownError;
}
return {
then(resolve: T => mixed, reject: mixed => mixed) {
didAwaitActCall = true;
@ -239,15 +274,20 @@ function recursivelyFlushAsyncActWork<T>(
queueMacrotask(() =>
recursivelyFlushAsyncActWork(returnValue, resolve, reject),
);
return;
} catch (error) {
// Leave remaining tasks on the queue if something throws.
reject(error);
ReactCurrentActQueue.thrownErrors.push(error);
}
} else {
// The queue is empty. We can finish.
ReactCurrentActQueue.current = null;
resolve(returnValue);
}
}
if (ReactCurrentActQueue.thrownErrors.length > 0) {
const thrownError = aggregateErrors(ReactCurrentActQueue.thrownErrors);
ReactCurrentActQueue.thrownErrors.length = 0;
reject(thrownError);
} else {
resolve(returnValue);
}
@ -287,7 +327,7 @@ function flushActQueue(queue: Array<RendererTask>) {
} catch (error) {
// If something throws, leave the remaining callbacks on the queue.
queue.splice(0, i + 1);
throw error;
ReactCurrentActQueue.thrownErrors.push(error);
} finally {
isFlushing = false;
}

View File

@ -20,6 +20,9 @@ const ReactCurrentActQueue = {
// Determines whether we should yield to microtasks to unwrap already resolved
// promises without suspending.
didUsePromise: false,
// Track first uncaught error within this act
thrownErrors: ([]: Array<mixed>),
};
export default ReactCurrentActQueue;

View File

@ -15,6 +15,8 @@ import {
enableTransitionTracing,
} from 'shared/ReactFeatureFlags';
import reportGlobalError from 'shared/reportGlobalError';
export function startTransition(
scope: () => void,
options?: StartTransitionOptions,
@ -51,10 +53,10 @@ export function startTransition(
typeof returnValue.then === 'function'
) {
callbacks.forEach(callback => callback(currentTransition, returnValue));
returnValue.then(noop, onError);
returnValue.then(noop, reportGlobalError);
}
} catch (error) {
onError(error);
reportGlobalError(error);
} finally {
warnAboutTransitionSubscriptions(prevTransition, currentTransition);
ReactCurrentBatchConfig.transition = prevTransition;
@ -91,16 +93,3 @@ function warnAboutTransitionSubscriptions(
}
function noop() {}
// Use reportError, if it exists. Otherwise console.error. This is the same as
// the default for onRecoverableError.
const onError =
typeof reportError === 'function'
? // In modern browsers, reportError will dispatch an error event,
// emulating an uncaught JavaScript error.
reportError
: (error: mixed) => {
// In older browsers and test environments, fallback to console.error.
// eslint-disable-next-line react-internal/no-production-logging
console['error'](error);
};

View File

@ -9,7 +9,6 @@ PropTypes = null
React = null
ReactDOM = null
ReactDOMClient = null
act = null
featureFlags = require 'shared/ReactFeatureFlags'
@ -49,16 +48,25 @@ describe 'ReactCoffeeScriptClass', ->
it 'throws if no render function is defined', ->
class Foo extends React.Component
caughtErrors = []
errorHandler = (event) ->
event.preventDefault()
caughtErrors.push(event.error)
window.addEventListener 'error', errorHandler;
expect(->
expect(->
ReactDOM.flushSync ->
root.render React.createElement(Foo)
).toThrow()
ReactDOM.flushSync ->
root.render React.createElement(Foo)
).toErrorDev([
# A failed component renders twice in DEV in concurrent mode
'No `render` method found on the Foo instance',
'No `render` method found on the Foo instance',
])
window.removeEventListener 'error', errorHandler;
expect(caughtErrors).toEqual([
expect.objectContaining(
message: expect.stringContaining('is not a function')
)
])
it 'renders a simple stateless component with prop', ->
class Foo extends React.Component

View File

@ -60,14 +60,29 @@ describe('ReactES6Class', () => {
it('throws if no render function is defined', () => {
class Foo extends React.Component {}
expect(() => {
expect(() => ReactDOM.flushSync(() => root.render(<Foo />))).toThrow();
}).toErrorDev([
// A failed component renders twice in DEV in concurrent mode
'Warning: No `render` method found on the Foo instance: ' +
'you may have forgotten to define `render`.',
'Warning: No `render` method found on the Foo instance: ' +
'you may have forgotten to define `render`.',
const caughtErrors = [];
function errorHandler(event) {
event.preventDefault();
caughtErrors.push(event.error);
}
window.addEventListener('error', errorHandler);
try {
expect(() => {
ReactDOM.flushSync(() => root.render(<Foo />));
}).toErrorDev([
// A failed component renders twice in DEV in concurrent mode
'Warning: No `render` method found on the Foo instance: ' +
'you may have forgotten to define `render`.',
'Warning: No `render` method found on the Foo instance: ' +
'you may have forgotten to define `render`.',
]);
} finally {
window.removeEventListener('error', errorHandler);
}
expect(caughtErrors).toEqual([
expect.objectContaining({
message: expect.stringContaining('is not a function'),
}),
]);
});

View File

@ -327,17 +327,27 @@ describe('ReactTypeScriptClass', function() {
});
it('throws if no render function is defined', function() {
expect(() => {
expect(() =>
class Foo extends React.Component {}
const caughtErrors = [];
function errorHandler(event) {
event.preventDefault();
caughtErrors.push(event.error);
}
window.addEventListener('error', errorHandler);
try {
expect(() => {
ReactDOM.flushSync(() => root.render(React.createElement(Empty)))
).toThrow();
}).toErrorDev([
// A failed component renders twice in DEV in concurrent mode
'Warning: No `render` method found on the Empty instance: ' +
'you may have forgotten to define `render`.',
'Warning: No `render` method found on the Empty instance: ' +
'you may have forgotten to define `render`.',
]);
}).toErrorDev([
// A failed component renders twice in DEV in concurrent mode
'Warning: No `render` method found on the Empty instance: ' +
'you may have forgotten to define `render`.',
'Warning: No `render` method found on the Empty instance: ' +
'you may have forgotten to define `render`.',
]);
} finally {
window.removeEventListener('error', errorHandler);
}
expect(caughtErrors.length).toBe(1);
});
it('renders a simple stateless component with prop', function() {

View File

@ -0,0 +1,52 @@
/**
* 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.
*
* @flow
*/
const reportGlobalError: (error: mixed) => void =
typeof reportError === 'function'
? // In modern browsers, reportError will dispatch an error event,
// emulating an uncaught JavaScript error.
reportError
: error => {
if (
typeof window === 'object' &&
typeof window.ErrorEvent === 'function'
) {
// Browser Polyfill
const message =
typeof error === 'object' &&
error !== null &&
typeof error.message === 'string'
? // eslint-disable-next-line react-internal/safe-string-coercion
String(error.message)
: // eslint-disable-next-line react-internal/safe-string-coercion
String(error);
const event = new window.ErrorEvent('error', {
bubbles: true,
cancelable: true,
message: message,
error: error,
});
const shouldLog = window.dispatchEvent(event);
if (!shouldLog) {
return;
}
} else if (
typeof process === 'object' &&
// $FlowFixMe[method-unbinding]
typeof process.emit === 'function'
) {
// Node Polyfill
process.emit('uncaughtException', error);
return;
}
// eslint-disable-next-line react-internal/no-production-logging
console['error'](error);
};
export default reportGlobalError;

View File

@ -143,7 +143,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
};
}
test('basic usage', async () => {
it('basic usage', async () => {
const store = createExternalStore('Initial');
function App() {
@ -165,7 +165,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
expect(container.textContent).toEqual('Updated');
});
test('skips re-rendering if nothing changes', async () => {
it('skips re-rendering if nothing changes', async () => {
const store = createExternalStore('Initial');
function App() {
@ -189,7 +189,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
expect(container.textContent).toEqual('Initial');
});
test('switch to a different store', async () => {
it('switch to a different store', async () => {
const storeA = createExternalStore(0);
const storeB = createExternalStore(0);
@ -242,7 +242,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
expect(container.textContent).toEqual('1');
});
test('selecting a specific value inside getSnapshot', async () => {
it('selecting a specific value inside getSnapshot', async () => {
const store = createExternalStore({a: 0, b: 0});
function A() {
@ -290,7 +290,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
// In React 18, you can't observe in between a sync render and its
// passive effects, so this is only relevant to legacy roots
// @gate enableUseSyncExternalStoreShim
test(
it(
"compares to current state before bailing out, even when there's a " +
'mutation in between the sync and passive effects',
async () => {
@ -334,7 +334,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
},
);
test('mutating the store in between render and commit when getSnapshot has changed', async () => {
it('mutating the store in between render and commit when getSnapshot has changed', async () => {
const store = createExternalStore({a: 1, b: 1});
const getSnapshotA = () => store.getState().a;
@ -394,7 +394,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
expect(container.textContent).toEqual('B2');
});
test('mutating the store in between render and commit when getSnapshot has _not_ changed', async () => {
it('mutating the store in between render and commit when getSnapshot has _not_ changed', async () => {
// Same as previous test, but `getSnapshot` does not change
const store = createExternalStore({a: 1, b: 1});
@ -453,7 +453,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
expect(container.textContent).toEqual('A1');
});
test("does not bail out if the previous update hasn't finished yet", async () => {
it("does not bail out if the previous update hasn't finished yet", async () => {
const store = createExternalStore(0);
function Child1() {
@ -492,7 +492,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
expect(container.textContent).toEqual('00');
});
test('uses the latest getSnapshot, even if it changed in the same batch as a store update', async () => {
it('uses the latest getSnapshot, even if it changed in the same batch as a store update', async () => {
const store = createExternalStore({a: 0, b: 0});
const getSnapshotA = () => store.getState().a;
@ -523,7 +523,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
expect(container.textContent).toEqual('2');
});
test('handles errors thrown by getSnapshot', async () => {
it('handles errors thrown by getSnapshot', async () => {
class ErrorBoundary extends React.Component {
state = {error: null};
static getDerivedStateFromError(error) {
@ -568,23 +568,41 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
expect(container.textContent).toEqual('0');
// Update that throws in a getSnapshot. We can catch it with an error boundary.
await act(() => {
store.set({value: 1, throwInGetSnapshot: true, throwInIsEqual: false});
});
if (gate(flags => !flags.enableUseSyncExternalStoreShim)) {
assertLog([
'Error in getSnapshot',
// In a concurrent root, React renders a second time to attempt to
// recover from the error.
'Error in getSnapshot',
]);
if (__DEV__ && gate(flags => flags.enableUseSyncExternalStoreShim)) {
// In 17, the error is re-thrown in DEV.
await expect(async () => {
await act(() => {
store.set({
value: 1,
throwInGetSnapshot: true,
throwInIsEqual: false,
});
});
}).rejects.toThrow('Error in getSnapshot');
} else {
assertLog(['Error in getSnapshot']);
await act(() => {
store.set({
value: 1,
throwInGetSnapshot: true,
throwInIsEqual: false,
});
});
}
assertLog(
gate(flags => flags.enableUseSyncExternalStoreShim)
? ['Error in getSnapshot']
: [
'Error in getSnapshot',
// In a concurrent root, React renders a second time to attempt to
// recover from the error.
'Error in getSnapshot',
],
);
expect(container.textContent).toEqual('Error in getSnapshot');
});
test('Infinite loop if getSnapshot keeps returning new reference', async () => {
it('Infinite loop if getSnapshot keeps returning new reference', async () => {
const store = createExternalStore({});
function App() {
@ -596,9 +614,11 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
const root = createRoot(container);
await expect(async () => {
expect(() =>
ReactDOM.flushSync(async () => root.render(<App />)),
).toThrow(
await expect(async () => {
await act(() => {
ReactDOM.flushSync(async () => root.render(<App />));
});
}).rejects.toThrow(
'Maximum update depth exceeded. This can happen when a component repeatedly ' +
'calls setState inside componentWillUpdate or componentDidUpdate. React limits ' +
'the number of nested updates to prevent infinite loops.',
@ -606,7 +626,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
}).toErrorDev(
gate(flags => flags.enableUseSyncExternalStoreShim)
? [
'Uncaught [',
'Maximum update depth exceeded. ',
'The result of getSnapshot should be cached to avoid an infinite loop',
'The above error occurred in the',
]
@ -625,7 +645,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
);
});
test('getSnapshot can return NaN without infinite loop warning', async () => {
it('getSnapshot can return NaN without infinite loop warning', async () => {
const store = createExternalStore('not a number');
function App() {
@ -655,7 +675,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
describe('extra features implemented in user-space', () => {
// The selector implementation uses the lazy ref initialization pattern
// @gate !(enableUseRefAccessWarning && __DEV__)
test('memoized selectors are only called once per update', async () => {
it('memoized selectors are only called once per update', async () => {
const store = createExternalStore({a: 0, b: 0});
function selector(state) {
@ -698,7 +718,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
// The selector implementation uses the lazy ref initialization pattern
// @gate !(enableUseRefAccessWarning && __DEV__)
test('Using isEqual to bailout', async () => {
it('Using isEqual to bailout', async () => {
const store = createExternalStore({a: 0, b: 0});
function A() {
@ -757,7 +777,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
expect(container.textContent).toEqual('A1B1');
});
test('basic server hydration', async () => {
it('basic server hydration', async () => {
const store = createExternalStore('client');
const ref = React.createRef();
@ -810,7 +830,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
});
});
test('regression test for #23150', async () => {
it('regression test for #23150', async () => {
const store = createExternalStore('Initial');
function App() {
@ -839,7 +859,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
// The selector implementation uses the lazy ref initialization pattern
// @gate !(enableUseRefAccessWarning && __DEV__)
test('compares selection to rendered selection even if selector changes', async () => {
it('compares selection to rendered selection even if selector changes', async () => {
const store = createExternalStore({items: ['A', 'B']});
const shallowEqualArray = (a, b) => {
@ -958,15 +978,31 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
expect(container.textContent).toEqual('A');
await expect(async () => {
await act(() => {
store.set({});
});
}).toWarnDev(
ReactFeatureFlags.enableUseRefAccessWarning
? ['Warning: App: Unsafe read of a mutable value during render.']
: [],
);
if (__DEV__ && gate(flags => flags.enableUseSyncExternalStoreShim)) {
// In 17, the error is re-thrown in DEV.
await expect(async () => {
await expect(async () => {
await act(() => {
store.set({});
});
}).rejects.toThrow('Malformed state');
}).toWarnDev(
ReactFeatureFlags.enableUseRefAccessWarning
? ['Warning: App: Unsafe read of a mutable value during render.']
: [],
);
} else {
await expect(async () => {
await act(() => {
store.set({});
});
}).toWarnDev(
ReactFeatureFlags.enableUseRefAccessWarning
? ['Warning: App: Unsafe read of a mutable value during render.']
: [],
);
}
expect(container.textContent).toEqual('Malformed state');
});
@ -1003,15 +1039,31 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
expect(container.textContent).toEqual('A');
await expect(async () => {
await act(() => {
store.set({});
});
}).toWarnDev(
ReactFeatureFlags.enableUseRefAccessWarning
? ['Warning: App: Unsafe read of a mutable value during render.']
: [],
);
if (__DEV__ && gate(flags => flags.enableUseSyncExternalStoreShim)) {
// In 17, the error is re-thrown in DEV.
await expect(async () => {
await expect(async () => {
await act(() => {
store.set({});
});
}).rejects.toThrow('Malformed state');
}).toWarnDev(
ReactFeatureFlags.enableUseRefAccessWarning
? ['Warning: App: Unsafe read of a mutable value during render.']
: [],
);
} else {
await expect(async () => {
await act(() => {
store.set({});
});
}).toWarnDev(
ReactFeatureFlags.enableUseRefAccessWarning
? ['Warning: App: Unsafe read of a mutable value during render.']
: [],
);
}
expect(container.textContent).toEqual('Malformed state');
});
});

View File

@ -71,11 +71,7 @@ const createMatcherFor = (consoleMethod, matcherName) =>
const consoleSpy = (format, ...args) => {
// Ignore uncaught errors reported by jsdom
// and React addendums because they're too noisy.
if (
!logAllErrors &&
consoleMethod === 'error' &&
shouldIgnoreConsoleError(format, args)
) {
if (!logAllErrors && shouldIgnoreConsoleError(format, args)) {
return;
}

View File

@ -68,7 +68,7 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) {
const newMethod = function (format, ...args) {
// Ignore uncaught errors reported by jsdom
// and React addendums because they're too noisy.
if (methodName === 'error' && shouldIgnoreConsoleError(format, args)) {
if (shouldIgnoreConsoleError(format, args)) {
return;
}

View File

@ -9,8 +9,11 @@ module.exports = function shouldIgnoreConsoleError(
if (typeof format === 'string') {
if (
args[0] != null &&
typeof args[0].message === 'string' &&
typeof args[0].stack === 'string'
((typeof args[0] === 'object' &&
typeof args[0].message === 'string' &&
typeof args[0].stack === 'string') ||
(typeof args[0] === 'string' &&
args[0].indexOf('An error occurred in ') === 0))
) {
// This looks like an error with addendum from ReactFiberErrorLogger.
// They are noisy too so we'll try to ignore them.

View File

@ -40,6 +40,7 @@ module.exports = {
// FB
__DEV__: 'readonly',
// Node.js Server Rendering
process: 'readonly',
setImmediate: 'readonly',
Buffer: 'readonly',
// Trusted Types

View File

@ -53,6 +53,9 @@ module.exports = {
reportError: 'readonly',
AggregateError: 'readonly',
// Node Feature Detection
process: 'readonly',
// Temp
AsyncLocalStorage: 'readonly',
async_hooks: 'readonly',

View File

@ -57,6 +57,9 @@ module.exports = {
// Flight
Promise: 'readonly',
// Node Feature Detection
process: 'readonly',
// Temp
AsyncLocalStorage: 'readonly',
async_hooks: 'readonly',