Skip to content

Commit 13b4b1a

Browse files
sebmarkbagerickhanlonii
authored andcommitted
[Fiber] Don't Rethrow Errors at the Root (facebook#28627)
Stacked on top of facebook#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>
1 parent c9df839 commit 13b4b1a

50 files changed

Lines changed: 1495 additions & 1037 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/internal-test-utils/ReactInternalTestUtils.js

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import simulateBrowserEventDispatch from './simulateBrowserEventDispatch';
1313

1414
export {act} from './internalAct';
1515

16+
import {thrownErrors, actingUpdatesScopeDepth} from './internalAct';
17+
1618
function assertYieldsWereCleared(caller) {
1719
const actualYields = SchedulerMock.unstable_clearLog();
1820
if (actualYields.length !== 0) {
@@ -110,6 +112,14 @@ ${diff(expectedLog, actualLog)}
110112
throw error;
111113
}
112114

115+
function aggregateErrors(errors: Array<mixed>): mixed {
116+
if (errors.length > 1 && typeof AggregateError === 'function') {
117+
// eslint-disable-next-line no-undef
118+
return new AggregateError(errors);
119+
}
120+
return errors[0];
121+
}
122+
113123
export async function waitForThrow(expectedError: mixed): mixed {
114124
assertYieldsWereCleared(waitForThrow);
115125

@@ -126,39 +136,80 @@ export async function waitForThrow(expectedError: mixed): mixed {
126136
error.message = 'Expected something to throw, but nothing did.';
127137
throw error;
128138
}
139+
140+
const errorHandlerDOM = function (event: ErrorEvent) {
141+
// Prevent logs from reprinting this error.
142+
event.preventDefault();
143+
thrownErrors.push(event.error);
144+
};
145+
const errorHandlerNode = function (err: mixed) {
146+
thrownErrors.push(err);
147+
};
148+
// We track errors that were logged globally as if they occurred in this scope and then rethrow them.
149+
if (actingUpdatesScopeDepth === 0) {
150+
if (
151+
typeof window === 'object' &&
152+
typeof window.addEventListener === 'function'
153+
) {
154+
// We're in a JS DOM environment.
155+
window.addEventListener('error', errorHandlerDOM);
156+
} else if (typeof process === 'object') {
157+
// Node environment
158+
process.on('uncaughtException', errorHandlerNode);
159+
}
160+
}
129161
try {
130162
SchedulerMock.unstable_flushAllWithoutAsserting();
131163
} catch (x) {
164+
thrownErrors.push(x);
165+
} finally {
166+
if (actingUpdatesScopeDepth === 0) {
167+
if (
168+
typeof window === 'object' &&
169+
typeof window.addEventListener === 'function'
170+
) {
171+
// We're in a JS DOM environment.
172+
window.removeEventListener('error', errorHandlerDOM);
173+
} else if (typeof process === 'object') {
174+
// Node environment
175+
process.off('uncaughtException', errorHandlerNode);
176+
}
177+
}
178+
}
179+
if (thrownErrors.length > 0) {
180+
const thrownError = aggregateErrors(thrownErrors);
181+
thrownErrors.length = 0;
182+
132183
if (expectedError === undefined) {
133184
// If no expected error was provided, then assume the caller is OK with
134185
// any error being thrown. We're returning the error so they can do
135186
// their own checks, if they wish.
136-
return x;
187+
return thrownError;
137188
}
138-
if (equals(x, expectedError)) {
139-
return x;
189+
if (equals(thrownError, expectedError)) {
190+
return thrownError;
140191
}
141192
if (
142193
typeof expectedError === 'string' &&
143-
typeof x === 'object' &&
144-
x !== null &&
145-
typeof x.message === 'string'
194+
typeof thrownError === 'object' &&
195+
thrownError !== null &&
196+
typeof thrownError.message === 'string'
146197
) {
147-
if (x.message.includes(expectedError)) {
148-
return x;
198+
if (thrownError.message.includes(expectedError)) {
199+
return thrownError;
149200
} else {
150201
error.message = `
151202
Expected error was not thrown.
152203
153-
${diff(expectedError, x.message)}
204+
${diff(expectedError, thrownError.message)}
154205
`;
155206
throw error;
156207
}
157208
}
158209
error.message = `
159210
Expected error was not thrown.
160211
161-
${diff(expectedError, x)}
212+
${diff(expectedError, thrownError)}
162213
`;
163214
throw error;
164215
}

packages/internal-test-utils/internalAct.js

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,24 @@ import * as Scheduler from 'scheduler/unstable_mock';
2020

2121
import enqueueTask from './enqueueTask';
2222

23-
let actingUpdatesScopeDepth: number = 0;
23+
export let actingUpdatesScopeDepth: number = 0;
24+
25+
export const thrownErrors: Array<mixed> = [];
2426

2527
async function waitForMicrotasks() {
2628
return new Promise(resolve => {
2729
enqueueTask(() => resolve());
2830
});
2931
}
3032

33+
function aggregateErrors(errors: Array<mixed>): mixed {
34+
if (errors.length > 1 && typeof AggregateError === 'function') {
35+
// eslint-disable-next-line no-undef
36+
return new AggregateError(errors);
37+
}
38+
return errors[0];
39+
}
40+
3141
export async function act<T>(scope: () => Thenable<T>): Thenable<T> {
3242
if (Scheduler.unstable_flushUntilNextPaint === undefined) {
3343
throw Error(
@@ -63,6 +73,28 @@ export async function act<T>(scope: () => Thenable<T>): Thenable<T> {
6373
// public version of `act`, though we maybe should in the future.
6474
await waitForMicrotasks();
6575

76+
const errorHandlerDOM = function (event: ErrorEvent) {
77+
// Prevent logs from reprinting this error.
78+
event.preventDefault();
79+
thrownErrors.push(event.error);
80+
};
81+
const errorHandlerNode = function (err: mixed) {
82+
thrownErrors.push(err);
83+
};
84+
// We track errors that were logged globally as if they occurred in this scope and then rethrow them.
85+
if (actingUpdatesScopeDepth === 1) {
86+
if (
87+
typeof window === 'object' &&
88+
typeof window.addEventListener === 'function'
89+
) {
90+
// We're in a JS DOM environment.
91+
window.addEventListener('error', errorHandlerDOM);
92+
} else if (typeof process === 'object') {
93+
// Node environment
94+
process.on('uncaughtException', errorHandlerNode);
95+
}
96+
}
97+
6698
try {
6799
const result = await scope();
68100

@@ -106,10 +138,27 @@ export async function act<T>(scope: () => Thenable<T>): Thenable<T> {
106138
Scheduler.unstable_flushUntilNextPaint();
107139
} while (true);
108140

141+
if (thrownErrors.length > 0) {
142+
// Rethrow any errors logged by the global error handling.
143+
const thrownError = aggregateErrors(thrownErrors);
144+
thrownErrors.length = 0;
145+
throw thrownError;
146+
}
147+
109148
return result;
110149
} finally {
111150
const depth = actingUpdatesScopeDepth;
112151
if (depth === 1) {
152+
if (
153+
typeof window === 'object' &&
154+
typeof window.addEventListener === 'function'
155+
) {
156+
// We're in a JS DOM environment.
157+
window.removeEventListener('error', errorHandlerDOM);
158+
} else if (typeof process === 'object') {
159+
// Node environment
160+
process.off('uncaughtException', errorHandlerNode);
161+
}
113162
global.IS_REACT_ACT_ENVIRONMENT = previousIsActEnvironment;
114163
}
115164
actingUpdatesScopeDepth = depth - 1;

packages/react-dom-bindings/src/events/DOMPluginEventSystem.js

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ import * as SelectEventPlugin from './plugins/SelectEventPlugin';
6868
import * as SimpleEventPlugin from './plugins/SimpleEventPlugin';
6969
import * as FormActionEventPlugin from './plugins/FormActionEventPlugin';
7070

71+
import reportGlobalError from 'shared/reportGlobalError';
72+
7173
type DispatchListener = {
7274
instance: null | Fiber,
7375
listener: Function,
@@ -226,9 +228,6 @@ export const nonDelegatedEvents: Set<DOMEventName> = new Set([
226228
...mediaEventTypes,
227229
]);
228230

229-
let hasError: boolean = false;
230-
let caughtError: mixed = null;
231-
232231
function executeDispatch(
233232
event: ReactSyntheticEvent,
234233
listener: Function,
@@ -238,12 +237,7 @@ function executeDispatch(
238237
try {
239238
listener(event);
240239
} catch (error) {
241-
if (!hasError) {
242-
hasError = true;
243-
caughtError = error;
244-
} else {
245-
// TODO: Make sure this error gets logged somehow.
246-
}
240+
reportGlobalError(error);
247241
}
248242
event.currentTarget = null;
249243
}
@@ -285,13 +279,6 @@ export function processDispatchQueue(
285279
processDispatchQueueItemsInOrder(event, listeners, inCapturePhase);
286280
// event system doesn't use pooling.
287281
}
288-
// This would be a good time to rethrow if any of the event handlers threw.
289-
if (hasError) {
290-
const error = caughtError;
291-
hasError = false;
292-
caughtError = null;
293-
throw error;
294-
}
295282
}
296283

297284
function dispatchEventsForPlugins(

packages/react-dom/src/__tests__/InvalidEventListeners-test.js

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,11 @@ describe('InvalidEventListeners', () => {
5151
}
5252
window.addEventListener('error', handleWindowError);
5353
try {
54-
await act(() => {
55-
node.dispatchEvent(
56-
new MouseEvent('click', {
57-
bubbles: true,
58-
}),
59-
);
60-
});
54+
node.dispatchEvent(
55+
new MouseEvent('click', {
56+
bubbles: true,
57+
}),
58+
);
6159
} finally {
6260
window.removeEventListener('error', handleWindowError);
6361
}

packages/react-dom/src/__tests__/ReactBrowserEventEmitter-test.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,9 +195,7 @@ describe('ReactBrowserEventEmitter', () => {
195195
});
196196
window.addEventListener('error', errorHandler);
197197
try {
198-
await act(() => {
199-
CHILD.click();
200-
});
198+
CHILD.click();
201199
expect(idCallOrder.length).toBe(3);
202200
expect(idCallOrder[0]).toBe(CHILD);
203201
expect(idCallOrder[1]).toBe(PARENT);

0 commit comments

Comments
 (0)