Skip to content

Commit

Permalink
[Fiber] Don't Rethrow Errors at the Root (#28627)
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
sebmarkbage and rickhanlonii committed Mar 27, 2024
1 parent 5910eb3 commit 6786563
Show file tree
Hide file tree
Showing 50 changed files with 1,495 additions and 1,037 deletions.
71 changes: 61 additions & 10 deletions packages/internal-test-utils/ReactInternalTestUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);

Expand All @@ -126,39 +136,80 @@ 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;
}
}
error.message = `
Expected error was not thrown.
${diff(expectedError, x)}
${diff(expectedError, thrownError)}
`;
throw error;
}
Expand Down
51 changes: 50 additions & 1 deletion packages/internal-test-utils/internalAct.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,24 @@ 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 => {
enqueueTask(() => resolve());
});
}

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(
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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;
Expand Down
19 changes: 3 additions & 16 deletions packages/react-dom-bindings/src/events/DOMPluginEventSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 5 additions & 7 deletions packages/react-dom/src/__tests__/InvalidEventListeners-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading

0 comments on commit 6786563

Please sign in to comment.