@samitouri / QOS-React / commits / 6786563f3c

[Fiber] Don't Rethrow Errors at the Root (#28627)

Stacked on top of #28498 for test fixes. ### Don't Rethrow When we started React it was 1:1 setState calls a series of renders and if they error, it errors where the setState was called. Simple. However, then batching came and the error actually got thrown somewhere else. With concurrent mode, it's not even possible to get setState itself to throw anymore. In fact, all APIs that can rethrow out of React are executed either at the root of the scheduler or inside a DOM event handler. If you throw inside a React.startTransition callback that's sync, then that will bubble out of the startTransition but if you throw inside an async callback or a useTransition we now need to handle it at the hook site. So in 19 we need to make all React.startTransition swallow the error (and report them to reportError). The only one remaining that can throw is flushSync but it doesn't really make sense for it to throw at the callsite neither because batching. Just because something rendered in this flush doesn't mean it was rendered due to what was just scheduled and doesn't mean that it should abort any of the remaining code afterwards. setState is fire and forget. It's send an instruction elsewhere, it's not part of the current imperative code. Error boundaries never rethrow. Since you should really always have error boundaries, most of the time, it wouldn't rethrow anyway. Rethrowing also actually currently drops errors on the floor since we can only rethrow the first error, so to avoid that we'd need to call reportError anyway. This happens in RN events. The other issue with rethrowing is that it logs an extra console.error. Since we're not sure that user code will actually log it anywhere we still log it too just like we do with errors inside error boundaries which leads all of these to log twice. The goal of this PR is to never rethrow out of React instead, errors outside of error boundaries get logged to reportError. Event system errors too. ### Breaking Changes The main thing this affects is testing where you want to inspect the errors thrown. To make it easier to port, if you're inside `act` we track the error into act in an aggregate error and then rethrow it at the root of `act`. Unlike before though, if you flush synchronously inside of act it'll still continue until the end of act before rethrowing. I expect most user code breakages would be to migrate from `flushSync` to `act` if you assert on throwing. However, in the React repo we also have `internalAct` and the `waitForThrow` helpers. Since these have to use public production implementations we track these using the global onerror or process uncaughtException. Unlike regular act, includes both event handler errors and onRecoverableError by default too. Not just render/commit errors. So I had to account for that in our tests. We restore logging an extra log for uncaught errors after the main log with the component stack in it. We use `console.warn`. This is not yet ignorable if you preventDefault to the main error event. To avoid confusion if you don't end up logging the error to console I just added `An error occurred`. ### Polyfill All browsers we support really supports `reportError` but not all test and server environments do, so I implemented a polyfill for browser and node in `shared/reportGlobalError`. I don't love that this is included in all builds and gets duplicated into isomorphic even though it's not actually needed in production. Maybe in the future we can require a polyfill for this. ### Follow Ups In a follow up, I'll make caught vs uncaught error handling be configurable too. --------- Co-authored-by: Ricky Hanlon <rickhanlonii@gmail.com>

Sebastian Markbåge committed Mar 26, 2024 at 20:44 UTC 6786563f3cbbc9b16d5a8187207b5bd904386e53
50 files changed +1495 -1037
packages/internal-test-utils/ReactInternalTestUtils.js
+61 -10
@@ -13,6 +13,8 @@ import simulateBrowserEventDispatch from './simulateBrowserEventDispatch';
13
14 export {act} from './internalAct';
15
16 +import {thrownErrors, actingUpdatesScopeDepth} from './internalAct';
17 +
18 function assertYieldsWereCleared(caller) {
19 const actualYields = SchedulerMock.unstable_clearLog();
20 if (actualYields.length !== 0) {
@@ -110,6 +112,14 @@ ${diff(expectedLog, actualLog)}
112 throw error;
113 }
114
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 +
123 export async function waitForThrow(expectedError: mixed): mixed {
124 assertYieldsWereCleared(waitForThrow);
125
@@ -126,31 +136,72 @@ export async function waitForThrow(expectedError: mixed): mixed {
136 error.message = 'Expected something to throw, but nothing did.';
137 throw error;
138 }
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 + }
161 try {
162 SchedulerMock.unstable_flushAllWithoutAsserting();
163 } 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 +
183 if (expectedError === undefined) {
184 // If no expected error was provided, then assume the caller is OK with
185 // any error being thrown. We're returning the error so they can do
186 // their own checks, if they wish.
136 - return x;
187 + return thrownError;
188 }
138 - if (equals(x, expectedError)) {
139 - return x;
189 + if (equals(thrownError, expectedError)) {
190 + return thrownError;
191 }
192 if (
193 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'
197 ) {
147 - if (x.message.includes(expectedError)) {
148 - return x;
198 + if (thrownError.message.includes(expectedError)) {
199 + return thrownError;
200 } else {
201 error.message = `
202 Expected error was not thrown.
203
153 -${diff(expectedError, x.message)}
204 +${diff(expectedError, thrownError.message)}
205 `;
206 throw error;
207 }
@@ -158,7 +209,7 @@ ${diff(expectedError, x.message)}
209 error.message = `
210 Expected error was not thrown.
211
161 -${diff(expectedError, x)}
212 +${diff(expectedError, thrownError)}
213 `;
214 throw error;
215 }
packages/internal-test-utils/internalAct.js
+50 -1
@@ -20,7 +20,9 @@ import * as Scheduler from 'scheduler/unstable_mock';
20
21 import enqueueTask from './enqueueTask';
22
23 -let actingUpdatesScopeDepth: number = 0;
23 +export let actingUpdatesScopeDepth: number = 0;
24 +
25 +export const thrownErrors: Array<mixed> = [];
26
27 async function waitForMicrotasks() {
28 return new Promise(resolve => {
@@ -28,6 +30,14 @@ async function waitForMicrotasks() {
30 });
31 }
32
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 +
41 export async function act<T>(scope: () => Thenable<T>): Thenable<T> {
42 if (Scheduler.unstable_flushUntilNextPaint === undefined) {
43 throw Error(
@@ -63,6 +73,28 @@ export async function act<T>(scope: () => Thenable<T>): Thenable<T> {
73 // public version of `act`, though we maybe should in the future.
74 await waitForMicrotasks();
75
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 +
98 try {
99 const result = await scope();
100
@@ -106,10 +138,27 @@ export async function act<T>(scope: () => Thenable<T>): Thenable<T> {
138 Scheduler.unstable_flushUntilNextPaint();
139 } while (true);
140
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 +
148 return result;
149 } finally {
150 const depth = actingUpdatesScopeDepth;
151 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 + }
162 global.IS_REACT_ACT_ENVIRONMENT = previousIsActEnvironment;
163 }
164 actingUpdatesScopeDepth = depth - 1;
packages/react-dom-bindings/src/events/DOMPluginEventSystem.js
+3 -16
@@ -68,6 +68,8 @@ import * as SelectEventPlugin from './plugins/SelectEventPlugin';
68 import * as SimpleEventPlugin from './plugins/SimpleEventPlugin';
69 import * as FormActionEventPlugin from './plugins/FormActionEventPlugin';
70
71 +import reportGlobalError from 'shared/reportGlobalError';
72 +
73 type DispatchListener = {
74 instance: null | Fiber,
75 listener: Function,
@@ -226,9 +228,6 @@ export const nonDelegatedEvents: Set<DOMEventName> = new Set([
228 ...mediaEventTypes,
229 ]);
230
229 -let hasError: boolean = false;
230 -let caughtError: mixed = null;
231 -
231 function executeDispatch(
232 event: ReactSyntheticEvent,
233 listener: Function,
@@ -238,12 +237,7 @@ function executeDispatch(
237 try {
238 listener(event);
239 } 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);
241 }
242 event.currentTarget = null;
243 }
@@ -285,13 +279,6 @@ export function processDispatchQueue(
279 processDispatchQueueItemsInOrder(event, listeners, inCapturePhase);
280 // event system doesn't use pooling.
281 }
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 - }
282 }
283
284 function dispatchEventsForPlugins(
packages/react-dom/src/__tests__/InvalidEventListeners-test.js
+5 -7
@@ -51,13 +51,11 @@ describe('InvalidEventListeners', () => {
51 }
52 window.addEventListener('error', handleWindowError);
53 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 + );
59 } finally {
60 window.removeEventListener('error', handleWindowError);
61 }
packages/react-dom/src/__tests__/ReactBrowserEventEmitter-test.js
+1 -3
@@ -195,9 +195,7 @@ describe('ReactBrowserEventEmitter', () => {
195 });
196 window.addEventListener('error', errorHandler);
197 try {
198 - await act(() => {
199 - CHILD.click();
200 - });
198 + CHILD.click();
199 expect(idCallOrder.length).toBe(3);
200 expect(idCallOrder[0]).toBe(CHILD);
201 expect(idCallOrder[1]).toBe(PARENT);
packages/react-dom/src/__tests__/ReactCompositeComponent-test.js
+29 -29
@@ -223,12 +223,12 @@ describe('ReactCompositeComponent', () => {
223
224 const el = document.createElement('div');
225 const root = ReactDOMClient.createRoot(el);
226 - expect(() => {
227 - expect(() => {
228 - ReactDOM.flushSync(() => {
226 + await expect(async () => {
227 + await expect(async () => {
228 + await act(() => {
229 root.render(<Child test="test" />);
230 });
231 - }).toThrow(
231 + }).rejects.toThrow(
232 'Objects are not valid as a React child (found: object with keys {render}).',
233 );
234 }).toErrorDev(
@@ -526,12 +526,12 @@ describe('ReactCompositeComponent', () => {
526 }
527 }
528 const root = ReactDOMClient.createRoot(container);
529 - expect(() => {
530 - expect(() => {
531 - ReactDOM.flushSync(() => {
529 + await expect(async () => {
530 + await expect(async () => {
531 + await act(() => {
532 root.render(<ClassWithRenderNotExtended />);
533 });
534 - }).toThrow(TypeError);
534 + }).rejects.toThrow(TypeError);
535 }).toErrorDev(
536 'Warning: The <ClassWithRenderNotExtended /> component appears to have a render method, ' +
537 "but doesn't extend React.Component. This is likely to cause errors. " +
@@ -539,11 +539,11 @@ describe('ReactCompositeComponent', () => {
539 );
540
541 // Test deduplication
542 - expect(() => {
543 - ReactDOM.flushSync(() => {
542 + await expect(async () => {
543 + await act(() => {
544 root.render(<ClassWithRenderNotExtended />);
545 });
546 - }).toThrow(TypeError);
546 + }).rejects.toThrow(TypeError);
547 });
548
549 it('should warn about `setState` in render', async () => {
@@ -596,11 +596,11 @@ describe('ReactCompositeComponent', () => {
596 expect(ReactCurrentOwner.current).toBe(null);
597
598 const root = ReactDOMClient.createRoot(document.createElement('div'));
599 - expect(() => {
600 - ReactDOM.flushSync(() => {
599 + await expect(async () => {
600 + await act(() => {
601 root.render(instance);
602 });
603 - }).toThrow();
603 + }).rejects.toThrow();
604
605 expect(ReactCurrentOwner.current).toBe(null);
606 });
@@ -884,7 +884,7 @@ describe('ReactCompositeComponent', () => {
884 );
885 });
886
887 - it('should only call componentWillUnmount once', () => {
887 + it('should only call componentWillUnmount once', async () => {
888 let app;
889 let count = 0;
890
@@ -919,14 +919,14 @@ describe('ReactCompositeComponent', () => {
919 };
920
921 const root = ReactDOMClient.createRoot(container);
922 - expect(() => {
923 - ReactDOM.flushSync(() => {
922 + await expect(async () => {
923 + await act(() => {
924 root.render(<App ref={setRef} stage={1} />);
925 });
926 - ReactDOM.flushSync(() => {
926 + await act(() => {
927 root.render(<App ref={setRef} stage={2} />);
928 });
929 - }).toThrow();
929 + }).rejects.toThrow();
930 expect(count).toBe(1);
931 });
932
@@ -1211,7 +1211,7 @@ describe('ReactCompositeComponent', () => {
1211 assertLog(['setState callback called']);
1212 });
1213
1214 - it('should return a meaningful warning when constructor is returned', () => {
1214 + it('should return a meaningful warning when constructor is returned', async () => {
1215 class RenderTextInvalidConstructor extends React.Component {
1216 constructor(props) {
1217 super(props);
@@ -1224,12 +1224,12 @@ describe('ReactCompositeComponent', () => {
1224 }
1225
1226 const root = ReactDOMClient.createRoot(document.createElement('div'));
1227 - expect(() => {
1228 - expect(() => {
1229 - ReactDOM.flushSync(() => {
1227 + await expect(async () => {
1228 + await expect(async () => {
1229 + await act(() => {
1230 root.render(<RenderTextInvalidConstructor />);
1231 });
1232 - }).toThrow();
1232 + }).rejects.toThrow();
1233 }).toErrorDev([
1234 'Warning: No `render` method found on the RenderTextInvalidConstructor instance: ' +
1235 'did you accidentally return an object from the constructor?',
@@ -1260,16 +1260,16 @@ describe('ReactCompositeComponent', () => {
1260 );
1261 });
1262
1263 - it('should return error if render is not defined', () => {
1263 + it('should return error if render is not defined', async () => {
1264 class RenderTestUndefinedRender extends React.Component {}
1265
1266 const root = ReactDOMClient.createRoot(document.createElement('div'));
1267 - expect(() => {
1268 - expect(() => {
1269 - ReactDOM.flushSync(() => {
1267 + await expect(async () => {
1268 + await expect(async () => {
1269 + await act(() => {
1270 root.render(<RenderTestUndefinedRender />);
1271 });
1272 - }).toThrow();
1272 + }).rejects.toThrow();
1273 }).toErrorDev([
1274 'Warning: No `render` method found on the RenderTestUndefinedRender instance: ' +
1275 'you may have forgotten to define `render`.',
packages/react-dom/src/__tests__/ReactDOM-test.js
+87 -54
@@ -166,6 +166,9 @@ describe('ReactDOM', () => {
166
167 // @gate !disableLegacyMode
168 it('throws in render() if the mount callback in legacy roots is not a function', async () => {
169 + spyOnDev(console, 'warn');
170 + spyOnDev(console, 'error');
171 +
172 function Foo() {
173 this.a = 1;
174 this.b = 2;
@@ -180,40 +183,55 @@ describe('ReactDOM', () => {
183 }
184
185 const myDiv = document.createElement('div');
183 - expect(() => {
184 - expect(() => {
185 - ReactDOM.render(<A />, myDiv, 'no');
186 - }).toErrorDev(
187 - 'Expected the last optional `callback` argument to be ' +
188 - 'a function. Instead received: no.',
186 + await expect(async () => {
187 + await expect(async () => {
188 + await act(() => {
189 + ReactDOM.render(<A />, myDiv, 'no');
190 + });
191 + }).rejects.toThrowError(
192 + 'Invalid argument passed as callback. Expected a function. Instead ' +
193 + 'received: no',
194 );
190 - }).toThrowError(
191 - 'Invalid argument passed as callback. Expected a function. Instead ' +
192 - 'received: no',
195 + }).toErrorDev(
196 + [
197 + 'Warning: Expected the last optional `callback` argument to be a function. Instead received: no.',
198 + 'Warning: Expected the last optional `callback` argument to be a function. Instead received: no.',
199 + ],
200 + {withoutStack: 2},
201 );
202
195 - expect(() => {
196 - expect(() => {
197 - ReactDOM.render(<A />, myDiv, {foo: 'bar'});
198 - }).toErrorDev(
199 - 'Expected the last optional `callback` argument to be ' +
200 - 'a function. Instead received: [object Object].',
203 + await expect(async () => {
204 + await expect(async () => {
205 + await act(() => {
206 + ReactDOM.render(<A />, myDiv, {foo: 'bar'});
207 + });
208 + }).rejects.toThrowError(
209 + 'Invalid argument passed as callback. Expected a function. Instead ' +
210 + 'received: [object Object]',
211 );
202 - }).toThrowError(
203 - 'Invalid argument passed as callback. Expected a function. Instead ' +
204 - 'received: [object Object]',
212 + }).toErrorDev(
213 + [
214 + 'Expected the last optional `callback` argument to be a function. Instead received: [object Object].',
215 + 'Expected the last optional `callback` argument to be a function. Instead received: [object Object].',
216 + ],
217 + {withoutStack: 2},
218 );
219
207 - expect(() => {
208 - expect(() => {
209 - ReactDOM.render(<A />, myDiv, new Foo());
210 - }).toErrorDev(
211 - 'Expected the last optional `callback` argument to be ' +
212 - 'a function. Instead received: [object Object].',
220 + await expect(async () => {
221 + await expect(async () => {
222 + await act(() => {
223 + ReactDOM.render(<A />, myDiv, new Foo());
224 + });
225 + }).rejects.toThrowError(
226 + 'Invalid argument passed as callback. Expected a function. Instead ' +
227 + 'received: [object Object]',
228 );
214 - }).toThrowError(
215 - 'Invalid argument passed as callback. Expected a function. Instead ' +
216 - 'received: [object Object]',
229 + }).toErrorDev(
230 + [
231 + 'Expected the last optional `callback` argument to be a function. Instead received: [object Object].',
232 + 'Expected the last optional `callback` argument to be a function. Instead received: [object Object].',
233 + ],
234 + {withoutStack: 2},
235 );
236 });
237
@@ -234,42 +252,57 @@ describe('ReactDOM', () => {
252
253 const myDiv = document.createElement('div');
254 ReactDOM.render(<A />, myDiv);
237 - expect(() => {
238 - expect(() => {
239 - ReactDOM.render(<A />, myDiv, 'no');
240 - }).toErrorDev(
241 - 'Expected the last optional `callback` argument to be ' +
242 - 'a function. Instead received: no.',
255 + await expect(async () => {
256 + await expect(async () => {
257 + await act(() => {
258 + ReactDOM.render(<A />, myDiv, 'no');
259 + });
260 + }).rejects.toThrowError(
261 + 'Invalid argument passed as callback. Expected a function. Instead ' +
262 + 'received: no',
263 );
244 - }).toThrowError(
245 - 'Invalid argument passed as callback. Expected a function. Instead ' +
246 - 'received: no',
264 + }).toErrorDev(
265 + [
266 + 'Expected the last optional `callback` argument to be a function. Instead received: no.',
267 + 'Expected the last optional `callback` argument to be a function. Instead received: no.',
268 + ],
269 + {withoutStack: 2},
270 );
271
272 ReactDOM.render(<A />, myDiv); // Re-mount
250 - expect(() => {
251 - expect(() => {
252 - ReactDOM.render(<A />, myDiv, {foo: 'bar'});
253 - }).toErrorDev(
254 - 'Expected the last optional `callback` argument to be ' +
255 - 'a function. Instead received: [object Object].',
273 + await expect(async () => {
274 + await expect(async () => {
275 + await act(() => {
276 + ReactDOM.render(<A />, myDiv, {foo: 'bar'});
277 + });
278 + }).rejects.toThrowError(
279 + 'Invalid argument passed as callback. Expected a function. Instead ' +
280 + 'received: [object Object]',
281 );
257 - }).toThrowError(
258 - 'Invalid argument passed as callback. Expected a function. Instead ' +
259 - 'received: [object Object]',
282 + }).toErrorDev(
283 + [
284 + 'Expected the last optional `callback` argument to be a function. Instead received: [object Object].',
285 + 'Expected the last optional `callback` argument to be a function. Instead received: [object Object].',
286 + ],
287 + {withoutStack: 2},
288 );
289
290 ReactDOM.render(<A />, myDiv); // Re-mount
263 - expect(() => {
264 - expect(() => {
265 - ReactDOM.render(<A />, myDiv, new Foo());
266 - }).toErrorDev(
267 - 'Expected the last optional `callback` argument to be ' +
268 - 'a function. Instead received: [object Object].',
291 + await expect(async () => {
292 + await expect(async () => {
293 + await act(() => {
294 + ReactDOM.render(<A />, myDiv, new Foo());
295 + });
296 + }).rejects.toThrowError(
297 + 'Invalid argument passed as callback. Expected a function. Instead ' +
298 + 'received: [object Object]',
299 );
270 - }).toThrowError(
271 - 'Invalid argument passed as callback. Expected a function. Instead ' +
272 - 'received: [object Object]',
300 + }).toErrorDev(
301 + [
302 + 'Expected the last optional `callback` argument to be a function. Instead received: [object Object].',
303 + 'Expected the last optional `callback` argument to be a function. Instead received: [object Object].',
304 + ],
305 + {withoutStack: 2},
306 );
307 });
308
packages/react-dom/src/__tests__/ReactDOMConsoleErrorReporting-test.js
+84 -52
@@ -16,16 +16,14 @@ describe('ReactDOMConsoleErrorReporting', () => {
16 let NoError;
17 let container;
18 let windowOnError;
19 - let waitForThrow;
19 + let Scheduler;
20
21 beforeEach(() => {
22 jest.resetModules();
23 act = require('internal-test-utils').act;
24 React = require('react');
25 ReactDOMClient = require('react-dom/client');
26 -
27 - const InternalTestUtils = require('internal-test-utils');
28 - waitForThrow = InternalTestUtils.waitForThrow;
26 + Scheduler = require('scheduler');
27
28 ErrorBoundary = class extends React.Component {
29 state = {error: null};
@@ -46,6 +44,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
44 document.body.appendChild(container);
45 windowOnError = jest.fn();
46 window.addEventListener('error', windowOnError);
47 + spyOnDevAndProd(console, 'error').mockImplementation(() => {});
48 + spyOnDevAndProd(console, 'warn').mockImplementation(() => {});
49 });
50
51 afterEach(() => {
@@ -54,11 +54,14 @@ describe('ReactDOMConsoleErrorReporting', () => {
54 jest.restoreAllMocks();
55 });
56
57 + async function fakeAct(cb) {
58 + // We don't use act/waitForThrow here because we want to observe how errors are reported for real.
59 + await cb();
60 + Scheduler.unstable_flushAll();
61 + }
62 +
63 describe('ReactDOMClient.createRoot', () => {
64 it('logs errors during event handlers', async () => {
59 - const originalError = console.error;
60 - console.error = jest.fn();
61 -
65 function Foo() {
66 return (
67 <button
@@ -75,13 +78,11 @@ describe('ReactDOMConsoleErrorReporting', () => {
78 root.render(<Foo />);
79 });
80
78 - await act(() => {
79 - container.firstChild.dispatchEvent(
80 - new MouseEvent('click', {
81 - bubbles: true,
82 - }),
83 - );
84 - });
81 + container.firstChild.dispatchEvent(
82 + new MouseEvent('click', {
83 + bubbles: true,
84 + }),
85 + );
86
87 expect(windowOnError.mock.calls).toEqual([
88 [
@@ -95,58 +96,64 @@ describe('ReactDOMConsoleErrorReporting', () => {
96 [
97 // Reported because we're in a browser click event:
98 expect.objectContaining({
98 - detail: expect.objectContaining({
99 - message: 'Boom',
100 - }),
101 - type: 'unhandled exception',
99 + message: 'Boom',
100 }),
101 ],
102 ]);
103
104 // Check next render doesn't throw.
105 windowOnError.mockReset();
108 - console.error = originalError;
106 + console.error.mockReset();
107 await act(() => {
108 root.render(<NoError />);
109 });
110 expect(container.textContent).toBe('OK');
111 expect(windowOnError.mock.calls).toEqual([]);
112 + expect(console.error.mock.calls).toEqual([]);
113 });
114
115 it('logs render errors without an error boundary', async () => {
117 - spyOnDevAndProd(console, 'error');
118 -
116 function Foo() {
117 throw Error('Boom');
118 }
119
120 const root = ReactDOMClient.createRoot(container);
124 - await act(async () => {
121 + await fakeAct(() => {
122 root.render(<Foo />);
126 - await waitForThrow('Boom');
123 });
124
125 if (__DEV__) {
130 - expect(windowOnError.mock.calls).toEqual([]);
126 + expect(windowOnError.mock.calls).toEqual([
127 + [
128 + expect.objectContaining({
129 + message: 'Boom',
130 + }),
131 + ],
132 + ]);
133 expect(console.error.mock.calls).toEqual([
134 [
133 - // Formatting
134 - expect.stringContaining('%o'),
135 expect.objectContaining({
136 message: 'Boom',
137 }),
138 + ],
139 + ]);
140 + expect(console.warn.mock.calls).toEqual([
141 + [
142 // Addendum by React:
139 - expect.stringContaining(
140 - 'The above error occurred in the <Foo> component',
141 - ),
143 + expect.stringContaining('%s'),
144 + expect.stringContaining('An error occurred in the <Foo> component'),
145 expect.stringContaining('Foo'),
146 expect.stringContaining('Consider adding an error boundary'),
147 ],
148 ]);
149 } else {
147 - // The top-level error was caught with try/catch,
148 - // so in production we don't see an error event.
149 - expect(windowOnError.mock.calls).toEqual([]);
150 + expect(windowOnError.mock.calls).toEqual([
151 + [
152 + expect.objectContaining({
153 + message: 'Boom',
154 + }),
155 + ],
156 + ]);
157 expect(console.error.mock.calls).toEqual([
158 [
159 // Reported by React with no extra message:
@@ -155,6 +162,7 @@ describe('ReactDOMConsoleErrorReporting', () => {
162 }),
163 ],
164 ]);
165 + expect(console.warn.mock.calls).toEqual([]);
166 }
167
168 // Check next render doesn't throw.
@@ -241,24 +249,30 @@ describe('ReactDOMConsoleErrorReporting', () => {
249 }
250
251 const root = ReactDOMClient.createRoot(container);
244 - await act(async () => {
252 + await fakeAct(() => {
253 root.render(<Foo />);
246 - await waitForThrow('Boom');
254 });
255
256 if (__DEV__) {
250 - expect(windowOnError.mock.calls).toEqual([]);
257 + expect(windowOnError.mock.calls).toEqual([
258 + [
259 + expect.objectContaining({
260 + message: 'Boom',
261 + }),
262 + ],
263 + ]);
264 expect(console.error.mock.calls).toEqual([
265 [
253 - // Formatting
254 - expect.stringContaining('%o'),
266 expect.objectContaining({
267 message: 'Boom',
268 }),
269 + ],
270 + ]);
271 + expect(console.warn.mock.calls).toEqual([
272 + [
273 // Addendum by React:
259 - expect.stringContaining(
260 - 'The above error occurred in the <Foo> component',
261 - ),
274 + expect.stringContaining('%s'),
275 + expect.stringContaining('An error occurred in the <Foo> component'),
276 expect.stringContaining('Foo'),
277 expect.stringContaining('Consider adding an error boundary'),
278 ],
@@ -266,7 +280,13 @@ describe('ReactDOMConsoleErrorReporting', () => {
280 } else {
281 // The top-level error was caught with try/catch,
282 // so in production we don't see an error event.
269 - expect(windowOnError.mock.calls).toEqual([]);
283 + expect(windowOnError.mock.calls).toEqual([
284 + [
285 + expect.objectContaining({
286 + message: 'Boom',
287 + }),
288 + ],
289 + ]);
290 expect(console.error.mock.calls).toEqual([
291 [
292 // Reported by React with no extra message:
@@ -275,6 +295,7 @@ describe('ReactDOMConsoleErrorReporting', () => {
295 }),
296 ],
297 ]);
298 + expect(console.warn.mock.calls).toEqual([]);
299 }
300
301 // Check next render doesn't throw.
@@ -364,32 +385,42 @@ describe('ReactDOMConsoleErrorReporting', () => {
385 }
386
387 const root = ReactDOMClient.createRoot(container);
367 - await act(async () => {
388 + await fakeAct(() => {
389 root.render(<Foo />);
369 - await waitForThrow('Boom');
390 });
391
392 if (__DEV__) {
373 - expect(windowOnError.mock.calls).toEqual([]);
393 + expect(windowOnError.mock.calls).toEqual([
394 + [
395 + expect.objectContaining({
396 + message: 'Boom',
397 + }),
398 + ],
399 + ]);
400 expect(console.error.mock.calls).toEqual([
401 [
376 - // Formatting
377 - expect.stringContaining('%o'),
402 expect.objectContaining({
403 message: 'Boom',
404 }),
405 + ],
406 + ]);
407 + expect(console.warn.mock.calls).toEqual([
408 + [
409 // Addendum by React:
382 - expect.stringContaining(
383 - 'The above error occurred in the <Foo> component',
384 - ),
410 + expect.stringContaining('%s'),
411 + expect.stringContaining('An error occurred in the <Foo> component'),
412 expect.stringContaining('Foo'),
413 expect.stringContaining('Consider adding an error boundary'),
414 ],
415 ]);
416 } else {
390 - // The top-level error was caught with try/catch,
391 - // so in production we don't see an error event.
392 - expect(windowOnError.mock.calls).toEqual([]);
417 + expect(windowOnError.mock.calls).toEqual([
418 + [
419 + expect.objectContaining({
420 + message: 'Boom',
421 + }),
422 + ],
423 + ]);
424 expect(console.error.mock.calls).toEqual([
425 [
426 // Reported by React with no extra message:
@@ -398,6 +429,7 @@ describe('ReactDOMConsoleErrorReporting', () => {
429 }),
430 ],
431 ]);
432 + expect(console.warn.mock.calls).toEqual([]);
433 }
434
435 // Check next render doesn't throw.
packages/react-dom/src/__tests__/ReactDOMConsoleErrorReportingLegacy-test.js
+146 -147
@@ -46,6 +46,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
46 document.body.appendChild(container);
47 windowOnError = jest.fn();
48 window.addEventListener('error', windowOnError);
49 + spyOnDevAndProd(console, 'error');
50 + spyOnDevAndProd(console, 'warn');
51 });
52
53 afterEach(() => {
@@ -57,9 +59,6 @@ describe('ReactDOMConsoleErrorReporting', () => {
59 describe('ReactDOM.render', () => {
60 // @gate !disableLegacyMode
61 it('logs errors during event handlers', async () => {
60 - const originalError = console.error;
61 - console.error = jest.fn();
62 -
62 function Foo() {
63 return (
64 <button
@@ -75,69 +74,52 @@ describe('ReactDOMConsoleErrorReporting', () => {
74 ReactDOM.render(<Foo />, container);
75 });
76
78 - await act(() => {
79 - container.firstChild.dispatchEvent(
80 - new MouseEvent('click', {
81 - bubbles: true,
77 + await expect(async () => {
78 + await act(() => {
79 + container.firstChild.dispatchEvent(
80 + new MouseEvent('click', {
81 + bubbles: true,
82 + }),
83 + );
84 + });
85 + }).rejects.toThrow(
86 + expect.objectContaining({
87 + message: 'Boom',
88 + }),
89 + );
90 +
91 + // Reported because we're in a browser click event:
92 + expect(windowOnError.mock.calls).toEqual([
93 + [
94 + expect.objectContaining({
95 + message: 'Boom',
96 }),
83 - );
84 - });
97 + ],
98 + ]);
99 + expect(console.warn).not.toBeCalled();
100
101 if (__DEV__) {
87 - expect(windowOnError.mock.calls).toEqual([
88 - [
89 - // Reported because we're in a browser click event:
90 - expect.objectContaining({
91 - message: 'Boom',
92 - }),
93 - ],
94 - ]);
102 expect(console.error.mock.calls).toEqual([
103 [
104 expect.stringContaining(
105 'ReactDOM.render has not been supported since React 18',
106 ),
107 ],
101 - [
102 - // Reported because we're in a browser click event:
103 - expect.objectContaining({
104 - detail: expect.objectContaining({
105 - message: 'Boom',
106 - }),
107 - type: 'unhandled exception',
108 - }),
109 - ],
108 ]);
109 } else {
112 - expect(windowOnError.mock.calls).toEqual([
113 - [
114 - // Reported because we're in a browser click event:
115 - expect.objectContaining({
116 - message: 'Boom',
117 - }),
118 - ],
119 - ]);
120 - expect(console.error.mock.calls).toEqual([
121 - [
122 - // Reported because we're in a browser click event:
123 - expect.objectContaining({
124 - detail: expect.objectContaining({
125 - message: 'Boom',
126 - }),
127 - type: 'unhandled exception',
128 - }),
129 - ],
130 - ]);
110 + expect(console.error).not.toBeCalled();
111 }
112
113 // Check next render doesn't throw.
114 windowOnError.mockReset();
115 + console.warn.mockReset();
116 console.error.mockReset();
117 await act(() => {
118 ReactDOM.render(<NoError />, container);
119 });
120 expect(container.textContent).toBe('OK');
140 - expect(windowOnError.mock.calls).toEqual([]);
121 + expect(windowOnError).not.toBeCalled();
122 + expect(console.warn).not.toBeCalled();
123 if (__DEV__) {
124 expect(console.error.mock.calls).toEqual([
125 [
@@ -146,66 +128,66 @@ describe('ReactDOMConsoleErrorReporting', () => {
128 ),
129 ],
130 ]);
131 + } else {
132 + expect(console.error).not.toBeCalled();
133 }
150 -
151 - console.error = originalError;
134 });
135
136 // @gate !disableLegacyMode
137 it('logs render errors without an error boundary', async () => {
156 - spyOnDevAndProd(console, 'error');
157 -
138 function Foo() {
139 throw Error('Boom');
140 }
141
162 - expect(() => {
163 - ReactDOM.render(<Foo />, container);
164 - }).toThrow('Boom');
142 + await expect(async () => {
143 + await act(() => {
144 + ReactDOM.render(<Foo />, container);
145 + });
146 + }).rejects.toThrow('Boom');
147 +
148 + // Reported because errors without a boundary are reported to window.
149 + expect(windowOnError.mock.calls).toEqual([
150 + [
151 + expect.objectContaining({
152 + message: 'Boom',
153 + }),
154 + ],
155 + ]);
156
157 if (__DEV__) {
167 - expect(console.error.mock.calls).toEqual([
168 - [
169 - expect.stringContaining(
170 - 'ReactDOM.render has not been supported since React 18',
171 - ),
172 - ],
158 + expect(console.warn.mock.calls).toEqual([
159 [
160 // Formatting
175 - expect.stringContaining('%o'),
176 - expect.objectContaining({
177 - message: 'Boom',
178 - }),
161 + expect.stringContaining('%s'),
162 // Addendum by React:
180 - expect.stringContaining(
181 - 'The above error occurred in the <Foo> component',
182 - ),
163 + expect.stringContaining('An error occurred in the <Foo> component'),
164 expect.stringContaining('Foo'),
165 expect.stringContaining('Consider adding an error boundary'),
166 ],
167 ]);
187 - } else {
188 - // The top-level error was caught with try/catch,
189 - // so in production we don't see an error event.
190 - expect(windowOnError.mock.calls).toEqual([]);
168 +
169 expect(console.error.mock.calls).toEqual([
170 [
193 - // Reported by React with no extra message:
194 - expect.objectContaining({
195 - message: 'Boom',
196 - }),
171 + expect.stringContaining(
172 + 'ReactDOM.render has not been supported since React 18',
173 + ),
174 ],
175 ]);
176 + } else {
177 + expect(console.warn).not.toBeCalled();
178 + expect(console.error).not.toBeCalled();
179 }
180
181 // Check next render doesn't throw.
182 windowOnError.mockReset();
183 + console.warn.mockReset();
184 console.error.mockReset();
185 await act(() => {
186 ReactDOM.render(<NoError />, container);
187 });
188 expect(container.textContent).toBe('OK');
208 - expect(windowOnError.mock.calls).toEqual([]);
189 + expect(console.warn).not.toBeCalled();
190 + expect(windowOnError).not.toBeCalled();
191 if (__DEV__) {
192 expect(console.error.mock.calls).toEqual([
193 [
@@ -214,13 +196,13 @@ describe('ReactDOMConsoleErrorReporting', () => {
196 ),
197 ],
198 ]);
199 + } else {
200 + expect(console.error).not.toBeCalled();
201 }
202 });
203
204 // @gate !disableLegacyMode
205 it('logs render errors with an error boundary', async () => {
222 - spyOnDevAndProd(console, 'error');
223 -
206 function Foo() {
207 throw Error('Boom');
208 }
@@ -234,8 +216,12 @@ describe('ReactDOMConsoleErrorReporting', () => {
216 );
217 });
218
219 + // The top-level error was caught with try/catch,
220 + // so we don't see an error event.
221 + expect(windowOnError).not.toBeCalled();
222 + expect(console.warn).not.toBeCalled();
223 +
224 if (__DEV__) {
238 - expect(windowOnError.mock.calls).toEqual([]);
225 expect(console.error.mock.calls).toEqual([
226 [
227 expect.stringContaining(
@@ -257,9 +243,6 @@ describe('ReactDOMConsoleErrorReporting', () => {
243 ],
244 ]);
245 } else {
260 - // The top-level error was caught with try/catch,
261 - // so in production we don't see an error event.
262 - expect(windowOnError.mock.calls).toEqual([]);
246 expect(console.error.mock.calls).toEqual([
247 [
248 // Reported by React with no extra message:
@@ -273,11 +256,13 @@ describe('ReactDOMConsoleErrorReporting', () => {
256 // Check next render doesn't throw.
257 windowOnError.mockReset();
258 console.error.mockReset();
259 + console.warn.mockReset();
260 await act(() => {
261 ReactDOM.render(<NoError />, container);
262 });
263 expect(container.textContent).toBe('OK');
280 - expect(windowOnError.mock.calls).toEqual([]);
264 + expect(windowOnError).not.toBeCalled();
265 + expect(console.warn).not.toBeCalled();
266 if (__DEV__) {
267 expect(console.error.mock.calls).toEqual([
268 [
@@ -286,13 +271,13 @@ describe('ReactDOMConsoleErrorReporting', () => {
271 ),
272 ],
273 ]);
274 + } else {
275 + expect(console.error).not.toBeCalled();
276 }
277 });
278
279 // @gate !disableLegacyMode
280 it('logs layout effect errors without an error boundary', async () => {
294 - spyOnDevAndProd(console, 'error');
295 -
281 function Foo() {
282 React.useLayoutEffect(() => {
283 throw Error('Boom');
@@ -300,54 +285,59 @@ describe('ReactDOMConsoleErrorReporting', () => {
285 return null;
286 }
287
303 - expect(() => {
304 - ReactDOM.render(<Foo />, container);
305 - }).toThrow('Boom');
288 + await expect(async () => {
289 + await act(() => {
290 + ReactDOM.render(<Foo />, container);
291 + });
292 + }).rejects.toThrow('Boom');
293 +
294 + // Reported because errors without a boundary are reported to window.
295 + expect(windowOnError.mock.calls).toEqual([
296 + [
297 + expect.objectContaining({
298 + message: 'Boom',
299 + }),
300 + ],
301 + ]);
302
303 if (__DEV__) {
308 - expect(windowOnError.mock.calls).toEqual([]);
309 - expect(console.error.mock.calls).toEqual([
310 - [
311 - expect.stringContaining(
312 - 'ReactDOM.render has not been supported since React 18',
313 - ),
314 - ],
304 + expect(console.warn.mock.calls).toEqual([
305 [
306 // Formatting
317 - expect.stringContaining('%o'),
318 - expect.objectContaining({
319 - message: 'Boom',
320 - }),
307 + expect.stringContaining('%s'),
308 +
309 // Addendum by React:
310 expect.stringContaining(
323 - 'The above error occurred in the <Foo> component',
311 + 'An error occurred in the <Foo> component:',
312 ),
313 expect.stringContaining('Foo'),
314 expect.stringContaining('Consider adding an error boundary'),
315 ],
316 ]);
329 - } else {
330 - // The top-level error was caught with try/catch,
331 - // so in production we don't see an error event.
332 - expect(windowOnError.mock.calls).toEqual([]);
317 +
318 expect(console.error.mock.calls).toEqual([
319 [
335 - // Reported by React with no extra message:
336 - expect.objectContaining({
337 - message: 'Boom',
338 - }),
320 + expect.stringContaining(
321 + 'ReactDOM.render has not been supported since React 18',
322 + ),
323 ],
324 ]);
325 + } else {
326 + expect(console.warn).not.toBeCalled();
327 + expect(console.error).not.toBeCalled();
328 }
329
330 // Check next render doesn't throw.
331 windowOnError.mockReset();
332 + console.warn.mockReset();
333 console.error.mockReset();
334 await act(() => {
335 ReactDOM.render(<NoError />, container);
336 });
337 expect(container.textContent).toBe('OK');
350 - expect(windowOnError.mock.calls).toEqual([]);
338 + expect(console.warn).not.toBeCalled();
339 + expect(windowOnError).not.toBeCalled();
340 +
341 if (__DEV__) {
342 expect(console.error.mock.calls).toEqual([
343 [
@@ -356,13 +346,13 @@ describe('ReactDOMConsoleErrorReporting', () => {
346 ),
347 ],
348 ]);
349 + } else {
350 + expect(console.error).not.toBeCalled();
351 }
352 });
353
354 // @gate !disableLegacyMode
355 it('logs layout effect errors with an error boundary', async () => {
364 - spyOnDevAndProd(console, 'error');
365 -
356 function Foo() {
357 React.useLayoutEffect(() => {
358 throw Error('Boom');
@@ -379,8 +369,12 @@ describe('ReactDOMConsoleErrorReporting', () => {
369 );
370 });
371
372 + // The top-level error was caught with try/catch,
373 + // so we don't see an error event.
374 + expect(windowOnError).not.toBeCalled();
375 + expect(console.warn).not.toBeCalled();
376 +
377 if (__DEV__) {
383 - expect(windowOnError.mock.calls).toEqual([]);
378 expect(console.error.mock.calls).toEqual([
379 [
380 expect.stringContaining(
@@ -402,9 +396,6 @@ describe('ReactDOMConsoleErrorReporting', () => {
396 ],
397 ]);
398 } else {
405 - // The top-level error was caught with try/catch,
406 - // so in production we don't see an error event.
407 - expect(windowOnError.mock.calls).toEqual([]);
399 expect(console.error.mock.calls).toEqual([
400 [
401 // Reported by React with no extra message:
@@ -417,12 +408,14 @@ describe('ReactDOMConsoleErrorReporting', () => {
408
409 // Check next render doesn't throw.
410 windowOnError.mockReset();
411 + console.warn.mockReset();
412 console.error.mockReset();
413 await act(() => {
414 ReactDOM.render(<NoError />, container);
415 });
416 expect(container.textContent).toBe('OK');
425 - expect(windowOnError.mock.calls).toEqual([]);
417 + expect(windowOnError).not.toBeCalled();
418 + expect(console.warn).not.toBeCalled();
419 if (__DEV__) {
420 expect(console.error.mock.calls).toEqual([
421 [
@@ -431,13 +424,13 @@ describe('ReactDOMConsoleErrorReporting', () => {
424 ),
425 ],
426 ]);
427 + } else {
428 + expect(console.error).not.toBeCalled();
429 }
430 });
431
432 // @gate !disableLegacyMode
433 it('logs passive effect errors without an error boundary', async () => {
439 - spyOnDevAndProd(console, 'error');
440 -
434 function Foo() {
435 React.useEffect(() => {
436 throw Error('Boom');
@@ -450,50 +443,51 @@ describe('ReactDOMConsoleErrorReporting', () => {
443 await waitForThrow('Boom');
444 });
445
446 + // The top-level error was caught with try/catch,
447 + // so we don't see an error event.
448 + expect(windowOnError.mock.calls).toEqual([
449 + [
450 + expect.objectContaining({
451 + message: 'Boom',
452 + }),
453 + ],
454 + ]);
455 +
456 if (__DEV__) {
454 - expect(windowOnError.mock.calls).toEqual([]);
455 - expect(console.error.mock.calls).toEqual([
456 - [
457 - expect.stringContaining(
458 - 'ReactDOM.render has not been supported since React 18',
459 - ),
460 - ],
457 + expect(console.warn.mock.calls).toEqual([
458 [
459 // Formatting
463 - expect.stringContaining('%o'),
464 - expect.objectContaining({
465 - message: 'Boom',
466 - }),
460 + expect.stringContaining('%s'),
461 +
462 // Addendum by React:
468 - expect.stringContaining(
469 - 'The above error occurred in the <Foo> component',
470 - ),
463 + expect.stringContaining('An error occurred in the <Foo> component'),
464 expect.stringContaining('Foo'),
465 expect.stringContaining('Consider adding an error boundary'),
466 ],
467 ]);
475 - } else {
476 - // The top-level error was caught with try/catch,
477 - // so in production we don't see an error event.
478 - expect(windowOnError.mock.calls).toEqual([]);
468 +
469 expect(console.error.mock.calls).toEqual([
470 [
481 - // Reported by React with no extra message:
482 - expect.objectContaining({
483 - message: 'Boom',
484 - }),
471 + expect.stringContaining(
472 + 'ReactDOM.render has not been supported since React 18',
473 + ),
474 ],
475 ]);
476 + } else {
477 + expect(console.warn).not.toBeCalled();
478 + expect(console.error).not.toBeCalled();
479 }
480
481 // Check next render doesn't throw.
482 windowOnError.mockReset();
483 + console.warn.mockReset();
484 console.error.mockReset();
485 await act(() => {
486 ReactDOM.render(<NoError />, container);
487 });
488 expect(container.textContent).toBe('OK');
496 - expect(windowOnError.mock.calls).toEqual([]);
489 + expect(windowOnError).not.toBeCalled();
490 + expect(console.warn).not.toBeCalled();
491 if (__DEV__) {
492 expect(console.error.mock.calls).toEqual([
493 [
@@ -502,13 +496,13 @@ describe('ReactDOMConsoleErrorReporting', () => {
496 ),
497 ],
498 ]);
499 + } else {
500 + expect(console.error).not.toBeCalled();
501 }
502 });
503
504 // @gate !disableLegacyMode
505 it('logs passive effect errors with an error boundary', async () => {
510 - spyOnDevAndProd(console, 'error');
511 -
506 function Foo() {
507 React.useEffect(() => {
508 throw Error('Boom');
@@ -525,8 +519,12 @@ describe('ReactDOMConsoleErrorReporting', () => {
519 );
520 });
521
522 + // The top-level error was caught with try/catch,
523 + // so we don't see an error event.
524 + expect(windowOnError).not.toBeCalled();
525 + expect(console.warn).not.toBeCalled();
526 +
527 if (__DEV__) {
529 - expect(windowOnError.mock.calls).toEqual([]);
528 expect(console.error.mock.calls).toEqual([
529 [
530 expect.stringContaining(
@@ -548,9 +546,6 @@ describe('ReactDOMConsoleErrorReporting', () => {
546 ],
547 ]);
548 } else {
551 - // The top-level error was caught with try/catch,
552 - // so in production we don't see an error event.
553 - expect(windowOnError.mock.calls).toEqual([]);
549 expect(console.error.mock.calls).toEqual([
550 [
551 // Reported by React with no extra message:
@@ -563,12 +558,14 @@ describe('ReactDOMConsoleErrorReporting', () => {
558
559 // Check next render doesn't throw.
560 windowOnError.mockReset();
561 + console.warn.mockReset();
562 console.error.mockReset();
563 await act(() => {
564 ReactDOM.render(<NoError />, container);
565 });
566 expect(container.textContent).toBe('OK');
571 - expect(windowOnError.mock.calls).toEqual([]);
567 + expect(windowOnError).not.toBeCalled();
568 + expect(console.warn).not.toBeCalled();
569 if (__DEV__) {
570 expect(console.error.mock.calls).toEqual([
571 [
@@ -577,6 +574,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
574 ),
575 ],
576 ]);
577 + } else {
578 + expect(console.warn).not.toBeCalled();
579 }
580 });
581 });
packages/react-dom/src/__tests__/ReactDOMFiber-test.js
+6 -4
@@ -1108,11 +1108,13 @@ describe('ReactDOMFiber', () => {
1108 // It's an error of type 'NotFoundError' with no message
1109 container.innerHTML = '<div>MEOW.</div>';
1110
1111 - expect(() => {
1112 - ReactDOM.flushSync(() => {
1113 - root.render(<div key="2">baz</div>);
1111 + await expect(async () => {
1112 + await act(() => {
1113 + ReactDOM.flushSync(() => {
1114 + root.render(<div key="2">baz</div>);
1115 + });
1116 });
1115 - }).toThrow('The node to be removed is not a child of this node');
1117 + }).rejects.toThrow('The node to be removed is not a child of this node');
1118 });
1119
1120 it('should not warn when doing an update to a container manually updated outside of React', async () => {
packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js
+6
@@ -17,6 +17,10 @@ let act;
17 const util = require('util');
18 const realConsoleError = console.error;
19
20 +function errorHandler() {
21 + // forward to console.error but don't fail the tests
22 +}
23 +
24 describe('ReactDOMServerHydration', () => {
25 let container;
26
@@ -27,12 +31,14 @@ describe('ReactDOMServerHydration', () => {
31 ReactDOMServer = require('react-dom/server');
32 act = React.act;
33
34 + window.addEventListener('error', errorHandler);
35 console.error = jest.fn();
36 container = document.createElement('div');
37 document.body.appendChild(container);
38 });
39
40 afterEach(() => {
41 + window.removeEventListener('error', errorHandler);
42 document.body.removeChild(container);
43 console.error = realConsoleError;
44 });
packages/react-dom/src/__tests__/ReactDOMLegacyFiber-test.js
+27 -22
@@ -12,11 +12,12 @@
12 const React = require('react');
13 const ReactDOM = require('react-dom');
14 const PropTypes = require('prop-types');
15 -
15 +let act;
16 describe('ReactDOMLegacyFiber', () => {
17 let container;
18
19 beforeEach(() => {
20 + act = require('internal-test-utils').act;
21 container = document.createElement('div');
22 document.body.appendChild(container);
23 });
@@ -656,18 +657,20 @@ describe('ReactDOMLegacyFiber', () => {
657 });
658
659 // @gate !disableLegacyMode
659 - it('should unwind namespaces on uncaught errors', () => {
660 + it('should unwind namespaces on uncaught errors', async () => {
661 function BrokenRender() {
662 throw new Error('Hello');
663 }
664
664 - expect(() => {
665 - assertNamespacesMatch(
666 - <svg {...expectSVG}>
667 - <BrokenRender />
668 - </svg>,
669 - );
670 - }).toThrow('Hello');
665 + await expect(async () => {
666 + await act(() => {
667 + assertNamespacesMatch(
668 + <svg {...expectSVG}>
669 + <BrokenRender />
670 + </svg>,
671 + );
672 + });
673 + }).rejects.toThrow('Hello');
674 assertNamespacesMatch(<div {...expectHTML} />);
675 });
676
@@ -1222,7 +1225,7 @@ describe('ReactDOMLegacyFiber', () => {
1225 });
1226
1227 // @gate !disableLegacyMode
1225 - it('should warn when replacing a container which was manually updated outside of React', () => {
1228 + it('should warn when replacing a container which was manually updated outside of React', async () => {
1229 // when not messing with the DOM outside of React
1230 ReactDOM.render(<div key="1">foo</div>, container);
1231 ReactDOM.render(<div key="1">bar</div>, container);
@@ -1232,18 +1235,20 @@ describe('ReactDOMLegacyFiber', () => {
1235 // It's an error of type 'NotFoundError' with no message
1236 container.innerHTML = '<div>MEOW.</div>';
1237
1235 - expect(() => {
1236 - expect(() =>
1237 - ReactDOM.render(<div key="2">baz</div>, container),
1238 - ).toErrorDev(
1239 - '' +
1240 - 'It looks like the React-rendered content of this container was ' +
1241 - 'removed without using React. This is not supported and will ' +
1242 - 'cause errors. Instead, call ReactDOM.unmountComponentAtNode ' +
1243 - 'to empty a container.',
1244 - {withoutStack: true},
1245 - );
1246 - }).toThrowError();
1238 + await expect(async () => {
1239 + await expect(async () => {
1240 + await act(() => {
1241 + ReactDOM.render(<div key="2">baz</div>, container);
1242 + });
1243 + }).rejects.toThrow('The node to be removed is not a child of this node.');
1244 + }).toErrorDev(
1245 + '' +
1246 + 'It looks like the React-rendered content of this container was ' +
1247 + 'removed without using React. This is not supported and will ' +
1248 + 'cause errors. Instead, call ReactDOM.unmountComponentAtNode ' +
1249 + 'to empty a container.',
1250 + {withoutStack: true},
1251 + );
1252 });
1253
1254 // @gate !disableLegacyMode
packages/react-dom/src/__tests__/ReactDOMRoot-test.js
+5 -3
@@ -319,9 +319,11 @@ describe('ReactDOMRoot', () => {
319 });
320 container.innerHTML = '';
321
322 - expect(() => {
323 - root.unmount();
324 - }).toThrow('The node to be removed is not a child of this node.');
322 + await expect(async () => {
323 + await act(() => {
324 + root.unmount();
325 + });
326 + }).rejects.toThrow('The node to be removed is not a child of this node.');
327 });
328
329 it('opts-in to concurrent default updates', async () => {
packages/react-dom/src/__tests__/ReactDOMSelect-test.js
+7 -1
@@ -1448,7 +1448,13 @@ describe('ReactDOMSelect', () => {
1448 </select>,
1449 );
1450 }),
1451 - ).rejects.toThrowError(new TypeError('prod message'));
1451 + ).rejects.toThrowError(
1452 + // eslint-disable-next-line no-undef
1453 + new AggregateError([
1454 + new TypeError('prod message'),
1455 + new TypeError('prod message'),
1456 + ]),
1457 + );
1458 }).toErrorDev([
1459 'The provided `value` attribute is an unsupported type TemporalLike.' +
1460 ' This value must be coerced to a string before using it here.',
packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+11 -8
@@ -330,7 +330,6 @@ describe('ReactDOMServerPartialHydration', () => {
330 'Component',
331 'Component',
332 'Component',
333 -
333 // Hydration mismatch is logged
334 "Hydration failed because the server rendered HTML didn't match the client.",
335 'There was an error while hydrating this Suspense boundary.',
@@ -1151,16 +1150,20 @@ describe('ReactDOMServerPartialHydration', () => {
1150
1151 shouldSuspend = true;
1152 await act(() => {
1154 - ReactDOMClient.hydrateRoot(container, <App hasB={false} />);
1153 + ReactDOMClient.hydrateRoot(container, <App hasB={false} />, {
1154 + onRecoverableError(error) {
1155 + Scheduler.log(normalizeError(error.message));
1156 + },
1157 + });
1158 });
1159
1157 - await expect(async () => {
1158 - await act(() => {
1159 - resolve();
1160 - });
1161 - }).toErrorDev([
1160 + await act(() => {
1161 + resolve();
1162 + });
1163 +
1164 + assertLog([
1165 "Hydration failed because the server rendered HTML didn't match the client.",
1163 - 'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
1166 + 'There was an error while hydrating this Suspense boundary.',
1167 ]);
1168
1169 expect(container.innerHTML).toContain('<span>A</span>');
packages/react-dom/src/__tests__/ReactErrorBoundaries-test.internal.js
+10 -12
@@ -643,7 +643,7 @@ describe('ReactErrorBoundaries', () => {
643 root.render(<BrokenComponentWillUnmount />);
644 });
645 await expect(async () => {
646 - root.unmount();
646 + await act(() => root.unmount());
647 }).rejects.toThrow('Hello');
648 });
649
@@ -2470,7 +2470,7 @@ describe('ReactErrorBoundaries', () => {
2470 ]);
2471 });
2472
2473 - it('passes first error when two errors happen in commit', async () => {
2473 + it('passes an aggregate error when two errors happen in commit', async () => {
2474 const errors = [];
2475 let caughtError;
2476 class Parent extends React.Component {
@@ -2501,15 +2501,14 @@ describe('ReactErrorBoundaries', () => {
2501 root.render(<Parent />);
2502 });
2503 } catch (e) {
2504 - if (e.message !== 'parent sad' && e.message !== 'child sad') {
2505 - throw e;
2506 - }
2504 caughtError = e;
2505 }
2506
2507 expect(errors).toEqual(['child sad', 'parent sad']);
2511 - // Error should be the first thrown
2512 - expect(caughtError.message).toBe('child sad');
2508 + expect(caughtError.errors).toEqual([
2509 + expect.objectContaining({message: 'child sad'}),
2510 + expect.objectContaining({message: 'parent sad'}),
2511 + ]);
2512 });
2513
2514 it('propagates uncaught error inside unbatched initial mount', async () => {
@@ -2561,15 +2560,14 @@ describe('ReactErrorBoundaries', () => {
2560 root.render(<Parent value={2} />);
2561 });
2562 } catch (e) {
2564 - if (e.message !== 'parent sad' && e.message !== 'child sad') {
2565 - throw e;
2566 - }
2563 caughtError = e;
2564 }
2565
2566 expect(errors).toEqual(['child sad', 'parent sad']);
2571 - // Error should be the first thrown
2572 - expect(caughtError.message).toBe('child sad');
2567 + expect(caughtError.errors).toEqual([
2568 + expect.objectContaining({message: 'child sad'}),
2569 + expect.objectContaining({message: 'parent sad'}),
2570 + ]);
2571 });
2572
2573 it('should warn if an error boundary with only componentDidCatch does not update state', async () => {
packages/react-dom/src/__tests__/ReactErrorLoggingRecovery-test.js
+18 -14
@@ -18,7 +18,7 @@ if (global.window) {
18 // The issue only reproduced when React was loaded before JSDOM.
19 const React = require('react');
20 const ReactDOMClient = require('react-dom/client');
21 -const act = require('internal-test-utils').act;
21 +const Scheduler = require('scheduler');
22
23 // Initialize JSDOM separately.
24 // We don't use our normal JSDOM setup because we want to load React first.
@@ -39,6 +39,12 @@ class Bad extends React.Component {
39 }
40 }
41
42 +async function fakeAct(cb) {
43 + // We don't use act/waitForThrow here because we want to observe how errors are reported for real.
44 + await cb();
45 + Scheduler.unstable_flushAll();
46 +}
47 +
48 describe('ReactErrorLoggingRecovery', () => {
49 const originalConsoleError = console.error;
50
@@ -55,20 +61,18 @@ describe('ReactErrorLoggingRecovery', () => {
61 it('should recover from errors in console.error', async function () {
62 const div = document.createElement('div');
63 const root = ReactDOMClient.createRoot(div);
58 - await expect(async () => {
59 - await act(() => {
60 - root.render(<Bad />);
61 - });
62 - await act(() => {
63 - root.render(<Bad />);
64 - });
65 - }).rejects.toThrow('no');
64 + await fakeAct(() => {
65 + root.render(<Bad />);
66 + });
67 + await fakeAct(() => {
68 + root.render(<Bad />);
69 + });
70 +
71 + expect(() => jest.runAllTimers()).toThrow('');
72
67 - await expect(async () => {
68 - await act(() => {
69 - root.render(<span>Hello</span>);
70 - });
71 - }).rejects.toThrow('Buggy console.error');
73 + await fakeAct(() => {
74 + root.render(<span>Hello</span>);
75 + });
76 expect(div.firstChild.textContent).toBe('Hello');
77 });
78 });
packages/react-dom/src/__tests__/ReactLegacyErrorBoundaries-test.internal.js
+113 -85
@@ -12,6 +12,7 @@
12 let PropTypes;
13 let React;
14 let ReactDOM;
15 +let act;
16
17 // TODO: Refactor this test once componentDidCatch setState is deprecated.
18 describe('ReactLegacyErrorBoundaries', () => {
@@ -40,6 +41,7 @@ describe('ReactLegacyErrorBoundaries', () => {
41 PropTypes = require('prop-types');
42 ReactDOM = require('react-dom');
43 React = require('react');
44 + act = require('internal-test-utils').act;
45
46 log = [];
47
@@ -586,63 +588,79 @@ describe('ReactLegacyErrorBoundaries', () => {
588 });
589
590 // @gate !disableLegacyMode
589 - it('does not swallow exceptions on mounting without boundaries', () => {
591 + it('does not swallow exceptions on mounting without boundaries', async () => {
592 let container = document.createElement('div');
591 - expect(() => {
592 - ReactDOM.render(<BrokenRender />, container);
593 - }).toThrow('Hello');
593 + await expect(async () => {
594 + await act(() => {
595 + ReactDOM.render(<BrokenRender />, container);
596 + });
597 + }).rejects.toThrow('Hello');
598
599 container = document.createElement('div');
596 - expect(() => {
597 - ReactDOM.render(<BrokenComponentWillMount />, container);
598 - }).toThrow('Hello');
600 + await expect(async () => {
601 + await act(() => {
602 + ReactDOM.render(<BrokenComponentWillMount />, container);
603 + });
604 + }).rejects.toThrow('Hello');
605
606 container = document.createElement('div');
601 - expect(() => {
602 - ReactDOM.render(<BrokenComponentDidMount />, container);
603 - }).toThrow('Hello');
607 + await expect(async () => {
608 + await act(() => {
609 + ReactDOM.render(<BrokenComponentDidMount />, container);
610 + });
611 + }).rejects.toThrow('Hello');
612 });
613
614 // @gate !disableLegacyMode
607 - it('does not swallow exceptions on updating without boundaries', () => {
615 + it('does not swallow exceptions on updating without boundaries', async () => {
616 let container = document.createElement('div');
617 ReactDOM.render(<BrokenComponentWillUpdate />, container);
610 - expect(() => {
611 - ReactDOM.render(<BrokenComponentWillUpdate />, container);
612 - }).toThrow('Hello');
618 + await expect(async () => {
619 + await act(() => {
620 + ReactDOM.render(<BrokenComponentWillUpdate />, container);
621 + });
622 + }).rejects.toThrow('Hello');
623
624 container = document.createElement('div');
625 ReactDOM.render(<BrokenComponentWillReceiveProps />, container);
616 - expect(() => {
617 - ReactDOM.render(<BrokenComponentWillReceiveProps />, container);
618 - }).toThrow('Hello');
626 + await expect(async () => {
627 + await act(() => {
628 + ReactDOM.render(<BrokenComponentWillReceiveProps />, container);
629 + });
630 + }).rejects.toThrow('Hello');
631
632 container = document.createElement('div');
633 ReactDOM.render(<BrokenComponentDidUpdate />, container);
622 - expect(() => {
623 - ReactDOM.render(<BrokenComponentDidUpdate />, container);
624 - }).toThrow('Hello');
634 + await expect(async () => {
635 + await act(() => {
636 + ReactDOM.render(<BrokenComponentDidUpdate />, container);
637 + });
638 + }).rejects.toThrow('Hello');
639 });
640
641 // @gate !disableLegacyMode
628 - it('does not swallow exceptions on unmounting without boundaries', () => {
642 + it('does not swallow exceptions on unmounting without boundaries', async () => {
643 const container = document.createElement('div');
644 ReactDOM.render(<BrokenComponentWillUnmount />, container);
631 - expect(() => {
632 - ReactDOM.unmountComponentAtNode(container);
633 - }).toThrow('Hello');
645 + await expect(async () => {
646 + await act(() => {
647 + ReactDOM.unmountComponentAtNode(container);
648 + });
649 + }).rejects.toThrow('Hello');
650 });
651
652 // @gate !disableLegacyMode
637 - it('prevents errors from leaking into other roots', () => {
653 + it('prevents errors from leaking into other roots', async () => {
654 const container1 = document.createElement('div');
655 const container2 = document.createElement('div');
656 const container3 = document.createElement('div');
657
658 ReactDOM.render(<span>Before 1</span>, container1);
643 - expect(() => {
644 - ReactDOM.render(<BrokenRender />, container2);
645 - }).toThrow('Hello');
659 + await expect(async () => {
660 + await act(() => {
661 + ReactDOM.render(<BrokenRender />, container2);
662 + });
663 + }).rejects.toThrow('Hello');
664 ReactDOM.render(
665 <ErrorBoundary>
666 <BrokenRender />
@@ -2124,39 +2142,41 @@ describe('ReactLegacyErrorBoundaries', () => {
2142 });
2143
2144 // @gate !disableLegacyMode
2127 - it('discards a bad root if the root component fails', () => {
2145 + it('discards a bad root if the root component fails', async () => {
2146 const X = null;
2147 const Y = undefined;
2130 - let err1;
2131 - let err2;
2148
2133 - try {
2134 - const container = document.createElement('div');
2135 - expect(() => ReactDOM.render(<X />, container)).toErrorDev(
2136 - 'React.createElement: type is invalid -- expected a string ' +
2137 - '(for built-in components) or a class/function ' +
2138 - '(for composite components) but got: null.',
2139 - );
2140 - } catch (err) {
2141 - err1 = err;
2142 - }
2143 - try {
2144 - const container = document.createElement('div');
2145 - expect(() => ReactDOM.render(<Y />, container)).toErrorDev(
2146 - 'React.createElement: type is invalid -- expected a string ' +
2147 - '(for built-in components) or a class/function ' +
2148 - '(for composite components) but got: undefined.',
2149 - );
2150 - } catch (err) {
2151 - err2 = err;
2152 - }
2149 + await expect(async () => {
2150 + await expect(async () => {
2151 + const container = document.createElement('div');
2152 + await act(() => {
2153 + ReactDOM.render(<X />, container);
2154 + });
2155 + }).rejects.toThrow('got: null');
2156 + }).toErrorDev(
2157 + 'Warning: React.jsx: type is invalid -- expected a string ' +
2158 + '(for built-in components) or a class/function ' +
2159 + '(for composite components) but got: null.',
2160 + {withoutStack: 1},
2161 + );
2162
2154 - expect(err1.message).toMatch(/got: null/);
2155 - expect(err2.message).toMatch(/got: undefined/);
2163 + await expect(async () => {
2164 + await expect(async () => {
2165 + const container = document.createElement('div');
2166 + await act(() => {
2167 + ReactDOM.render(<Y />, container);
2168 + });
2169 + }).rejects.toThrow('got: undefined');
2170 + }).toErrorDev(
2171 + 'Warning: React.jsx: type is invalid -- expected a string ' +
2172 + '(for built-in components) or a class/function ' +
2173 + '(for composite components) but got: undefined.',
2174 + {withoutStack: 1},
2175 + );
2176 });
2177
2178 // @gate !disableLegacyMode
2159 - it('renders empty output if error boundary does not handle the error', () => {
2179 + it('renders empty output if error boundary does not handle the error', async () => {
2180 const container = document.createElement('div');
2181 expect(() => {
2182 ReactDOM.render(
@@ -2191,9 +2211,8 @@ describe('ReactLegacyErrorBoundaries', () => {
2211 });
2212
2213 // @gate !disableLegacyMode
2194 - it('passes first error when two errors happen in commit', () => {
2214 + it('passes first error when two errors happen in commit', async () => {
2215 const errors = [];
2196 - let caughtError;
2216 class Parent extends React.Component {
2217 render() {
2218 return <Child />;
@@ -2214,39 +2233,42 @@ describe('ReactLegacyErrorBoundaries', () => {
2233 }
2234
2235 const container = document.createElement('div');
2217 - try {
2218 - // Here, we test the behavior where there is no error boundary and we
2219 - // delegate to the host root.
2220 - ReactDOM.render(<Parent />, container);
2221 - } catch (e) {
2222 - if (e.message !== 'parent sad' && e.message !== 'child sad') {
2223 - throw e;
2224 - }
2225 - caughtError = e;
2226 - }
2236 + await expect(async () => {
2237 + await act(() => {
2238 + // Here, we test the behavior where there is no error boundary and we
2239 + // delegate to the host root.
2240 + ReactDOM.render(<Parent />, container);
2241 + });
2242 + }).rejects.toThrow(
2243 + expect.objectContaining({
2244 + errors: [
2245 + expect.objectContaining({message: 'child sad'}),
2246 + expect.objectContaining({message: 'parent sad'}),
2247 + ],
2248 + }),
2249 + );
2250
2251 expect(errors).toEqual(['child sad', 'parent sad']);
2229 - // Error should be the first thrown
2230 - expect(caughtError.message).toBe('child sad');
2252 });
2253
2254 // @gate !disableLegacyMode
2234 - it('propagates uncaught error inside unbatched initial mount', () => {
2255 + it('propagates uncaught error inside unbatched initial mount', async () => {
2256 function Foo() {
2257 throw new Error('foo error');
2258 }
2259 const container = document.createElement('div');
2239 - expect(() => {
2240 - ReactDOM.unstable_batchedUpdates(() => {
2241 - ReactDOM.render(<Foo />, container);
2260 + await expect(async () => {
2261 + await act(() => {
2262 + ReactDOM.unstable_batchedUpdates(() => {
2263 + ReactDOM.render(<Foo />, container);
2264 + });
2265 });
2243 - }).toThrow('foo error');
2266 + }).rejects.toThrow('foo error');
2267 });
2268
2269 // @gate !disableLegacyMode
2247 - it('handles errors that occur in before-mutation commit hook', () => {
2270 + it('handles errors that occur in before-mutation commit hook', async () => {
2271 const errors = [];
2249 - let caughtError;
2272 class Parent extends React.Component {
2273 getSnapshotBeforeUpdate() {
2274 errors.push('parent sad');
@@ -2269,18 +2291,24 @@ describe('ReactLegacyErrorBoundaries', () => {
2291 }
2292
2293 const container = document.createElement('div');
2272 - ReactDOM.render(<Parent value={1} />, container);
2273 - try {
2274 - ReactDOM.render(<Parent value={2} />, container);
2275 - } catch (e) {
2276 - if (e.message !== 'parent sad' && e.message !== 'child sad') {
2277 - throw e;
2278 - }
2279 - caughtError = e;
2280 - }
2294 + await act(() => {
2295 + ReactDOM.render(<Parent value={1} />, container);
2296 + });
2297 +
2298 + await expect(async () => {
2299 + await act(() => {
2300 + ReactDOM.render(<Parent value={2} />, container);
2301 + });
2302 + }).rejects.toThrow(
2303 + expect.objectContaining({
2304 + errors: [
2305 + expect.objectContaining({message: 'child sad'}),
2306 + expect.objectContaining({message: 'parent sad'}),
2307 + ],
2308 + }),
2309 + );
2310
2311 expect(errors).toEqual(['child sad', 'parent sad']);
2312 // Error should be the first thrown
2284 - expect(caughtError.message).toBe('child sad');
2313 });
2314 });
packages/react-dom/src/__tests__/ReactLegacyUpdates-test.js
+107 -62
@@ -886,7 +886,7 @@ describe('ReactLegacyUpdates', () => {
886 });
887
888 // @gate !disableLegacyMode
889 - it('throws in setState if the update callback is not a function', () => {
889 + it('throws in setState if the update callback is not a function', async () => {
890 function Foo() {
891 this.a = 1;
892 this.b = 2;
@@ -903,37 +903,52 @@ describe('ReactLegacyUpdates', () => {
903 let container = document.createElement('div');
904 let component = ReactDOM.render(<A />, container);
905
906 - expect(() => {
907 - expect(() => component.setState({}, 'no')).toErrorDev(
908 - 'Expected the last optional `callback` argument to be ' +
909 - 'a function. Instead received: no.',
906 + await expect(async () => {
907 + await expect(async () => {
908 + await act(() => {
909 + component.setState({}, 'no');
910 + });
911 + }).rejects.toThrowError(
912 + 'Invalid argument passed as callback. Expected a function. Instead ' +
913 + 'received: no',
914 );
911 - }).toThrowError(
912 - 'Invalid argument passed as callback. Expected a function. Instead ' +
913 - 'received: no',
915 + }).toErrorDev(
916 + 'Expected the last optional `callback` argument to be ' +
917 + 'a function. Instead received: no.',
918 + {withoutStack: 1},
919 );
920 +
921 container = document.createElement('div');
922 component = ReactDOM.render(<A />, container);
917 - expect(() => {
918 - expect(() => component.setState({}, {foo: 'bar'})).toErrorDev(
919 - 'Expected the last optional `callback` argument to be ' +
920 - 'a function. Instead received: [object Object].',
923 + await expect(async () => {
924 + await expect(async () => {
925 + await act(() => {
926 + component.setState({}, {foo: 'bar'});
927 + });
928 + }).rejects.toThrowError(
929 + 'Invalid argument passed as callback. Expected a function. Instead ' +
930 + 'received: [object Object]',
931 );
922 - }).toThrowError(
923 - 'Invalid argument passed as callback. Expected a function. Instead ' +
924 - 'received: [object Object]',
932 + }).toErrorDev(
933 + 'Expected the last optional `callback` argument to be ' +
934 + 'a function. Instead received: [object Object].',
935 + {withoutStack: 1},
936 );
937 // Make sure the warning is deduplicated and doesn't fire again
938 container = document.createElement('div');
939 component = ReactDOM.render(<A />, container);
929 - expect(() => component.setState({}, new Foo())).toThrowError(
940 + await expect(async () => {
941 + await act(() => {
942 + component.setState({}, new Foo());
943 + });
944 + }).rejects.toThrowError(
945 'Invalid argument passed as callback. Expected a function. Instead ' +
946 'received: [object Object]',
947 );
948 });
949
950 // @gate !disableLegacyMode
936 - it('throws in forceUpdate if the update callback is not a function', () => {
951 + it('throws in forceUpdate if the update callback is not a function', async () => {
952 function Foo() {
953 this.a = 1;
954 this.b = 2;
@@ -950,30 +965,44 @@ describe('ReactLegacyUpdates', () => {
965 let container = document.createElement('div');
966 let component = ReactDOM.render(<A />, container);
967
953 - expect(() => {
954 - expect(() => component.forceUpdate('no')).toErrorDev(
955 - 'Expected the last optional `callback` argument to be ' +
956 - 'a function. Instead received: no.',
968 + await expect(async () => {
969 + await expect(async () => {
970 + await act(() => {
971 + component.forceUpdate('no');
972 + });
973 + }).rejects.toThrowError(
974 + 'Invalid argument passed as callback. Expected a function. Instead ' +
975 + 'received: no',
976 );
958 - }).toThrowError(
959 - 'Invalid argument passed as callback. Expected a function. Instead ' +
960 - 'received: no',
977 + }).toErrorDev(
978 + 'Expected the last optional `callback` argument to be ' +
979 + 'a function. Instead received: no.',
980 + {withoutStack: 1},
981 );
982 container = document.createElement('div');
983 component = ReactDOM.render(<A />, container);
964 - expect(() => {
965 - expect(() => component.forceUpdate({foo: 'bar'})).toErrorDev(
966 - 'Expected the last optional `callback` argument to be ' +
967 - 'a function. Instead received: [object Object].',
984 + await expect(async () => {
985 + await expect(async () => {
986 + await act(() => {
987 + component.forceUpdate({foo: 'bar'});
988 + });
989 + }).rejects.toThrowError(
990 + 'Invalid argument passed as callback. Expected a function. Instead ' +
991 + 'received: [object Object]',
992 );
969 - }).toThrowError(
970 - 'Invalid argument passed as callback. Expected a function. Instead ' +
971 - 'received: [object Object]',
993 + }).toErrorDev(
994 + 'Expected the last optional `callback` argument to be ' +
995 + 'a function. Instead received: [object Object].',
996 + {withoutStack: 1},
997 );
998 // Make sure the warning is deduplicated and doesn't fire again
999 container = document.createElement('div');
1000 component = ReactDOM.render(<A />, container);
976 - expect(() => component.forceUpdate(new Foo())).toThrowError(
1001 + await expect(async () => {
1002 + await act(() => {
1003 + component.forceUpdate(new Foo());
1004 + });
1005 + }).rejects.toThrowError(
1006 'Invalid argument passed as callback. Expected a function. Instead ' +
1007 'received: [object Object]',
1008 );
@@ -1377,7 +1406,7 @@ describe('ReactLegacyUpdates', () => {
1406 });
1407
1408 // @gate !disableLegacyMode
1380 - it('resets the update counter for unrelated updates', () => {
1409 + it('resets the update counter for unrelated updates', async () => {
1410 const container = document.createElement('div');
1411 const ref = React.createRef();
1412
@@ -1397,9 +1426,11 @@ describe('ReactLegacyUpdates', () => {
1426 }
1427
1428 let limit = 55;
1400 - expect(() => {
1401 - ReactDOM.render(<EventuallyTerminating ref={ref} />, container);
1402 - }).toThrow('Maximum');
1429 + await expect(async () => {
1430 + await act(() => {
1431 + ReactDOM.render(<EventuallyTerminating ref={ref} />, container);
1432 + });
1433 + }).rejects.toThrow('Maximum');
1434
1435 // Verify that we don't go over the limit if these updates are unrelated.
1436 limit -= 10;
@@ -1411,14 +1442,16 @@ describe('ReactLegacyUpdates', () => {
1442 expect(container.textContent).toBe(limit.toString());
1443
1444 limit += 10;
1414 - expect(() => {
1415 - ref.current.setState({step: 0});
1416 - }).toThrow('Maximum');
1445 + await expect(async () => {
1446 + await act(() => {
1447 + ref.current.setState({step: 0});
1448 + });
1449 + }).rejects.toThrow('Maximum');
1450 expect(ref.current).toBe(null);
1451 });
1452
1453 // @gate !disableLegacyMode
1421 - it('does not fall into an infinite update loop', () => {
1454 + it('does not fall into an infinite update loop', async () => {
1455 class NonTerminating extends React.Component {
1456 state = {step: 0};
1457 componentDidMount() {
@@ -1438,13 +1471,15 @@ describe('ReactLegacyUpdates', () => {
1471 }
1472
1473 const container = document.createElement('div');
1441 - expect(() => {
1442 - ReactDOM.render(<NonTerminating />, container);
1443 - }).toThrow('Maximum');
1474 + await expect(async () => {
1475 + await act(() => {
1476 + ReactDOM.render(<NonTerminating />, container);
1477 + });
1478 + }).rejects.toThrow('Maximum');
1479 });
1480
1481 // @gate !disableLegacyMode
1447 - it('does not fall into an infinite update loop with useLayoutEffect', () => {
1482 + it('does not fall into an infinite update loop with useLayoutEffect', async () => {
1483 function NonTerminating() {
1484 const [step, setStep] = React.useState(0);
1485 React.useLayoutEffect(() => {
@@ -1454,13 +1489,15 @@ describe('ReactLegacyUpdates', () => {
1489 }
1490
1491 const container = document.createElement('div');
1457 - expect(() => {
1458 - ReactDOM.render(<NonTerminating />, container);
1459 - }).toThrow('Maximum');
1492 + await expect(async () => {
1493 + await act(() => {
1494 + ReactDOM.render(<NonTerminating />, container);
1495 + });
1496 + }).rejects.toThrow('Maximum');
1497 });
1498
1499 // @gate !disableLegacyMode
1463 - it('can recover after falling into an infinite update loop', () => {
1500 + it('can recover after falling into an infinite update loop', async () => {
1501 class NonTerminating extends React.Component {
1502 state = {step: 0};
1503 componentDidMount() {
@@ -1485,23 +1522,27 @@ describe('ReactLegacyUpdates', () => {
1522 }
1523
1524 const container = document.createElement('div');
1488 - expect(() => {
1489 - ReactDOM.render(<NonTerminating />, container);
1490 - }).toThrow('Maximum');
1525 + await expect(async () => {
1526 + await act(() => {
1527 + ReactDOM.render(<NonTerminating />, container);
1528 + });
1529 + }).rejects.toThrow('Maximum');
1530
1531 ReactDOM.render(<Terminating />, container);
1532 expect(container.textContent).toBe('1');
1533
1495 - expect(() => {
1496 - ReactDOM.render(<NonTerminating />, container);
1497 - }).toThrow('Maximum');
1534 + await expect(async () => {
1535 + await act(() => {
1536 + ReactDOM.render(<NonTerminating />, container);
1537 + });
1538 + }).rejects.toThrow('Maximum');
1539
1540 ReactDOM.render(<Terminating />, container);
1541 expect(container.textContent).toBe('1');
1542 });
1543
1544 // @gate !disableLegacyMode
1504 - it('does not fall into mutually recursive infinite update loop with same container', () => {
1545 + it('does not fall into mutually recursive infinite update loop with same container', async () => {
1546 // Note: this test would fail if there were two or more different roots.
1547
1548 class A extends React.Component {
@@ -1523,13 +1564,15 @@ describe('ReactLegacyUpdates', () => {
1564 }
1565
1566 const container = document.createElement('div');
1526 - expect(() => {
1527 - ReactDOM.render(<A />, container);
1528 - }).toThrow('Maximum');
1567 + await expect(async () => {
1568 + await act(() => {
1569 + ReactDOM.render(<A />, container);
1570 + });
1571 + }).rejects.toThrow('Maximum');
1572 });
1573
1574 // @gate !disableLegacyMode
1532 - it('does not fall into an infinite error loop', () => {
1575 + it('does not fall into an infinite error loop', async () => {
1576 function BadRender() {
1577 throw new Error('error');
1578 }
@@ -1557,9 +1600,11 @@ describe('ReactLegacyUpdates', () => {
1600 }
1601
1602 const container = document.createElement('div');
1560 - expect(() => {
1561 - ReactDOM.render(<NonTerminating />, container);
1562 - }).toThrow('Maximum');
1603 + await expect(async () => {
1604 + await act(() => {
1605 + ReactDOM.render(<NonTerminating />, container);
1606 + });
1607 + }).rejects.toThrow('Maximum');
1608 });
1609
1610 // @gate !disableLegacyMode
packages/react-dom/src/__tests__/ReactUpdates-test.js
+40 -38
@@ -1542,11 +1542,11 @@ describe('ReactUpdates', () => {
1542
1543 let limit = 55;
1544 const root = ReactDOMClient.createRoot(container);
1545 - expect(() => {
1546 - ReactDOM.flushSync(() => {
1545 + await expect(async () => {
1546 + await act(() => {
1547 root.render(<EventuallyTerminating ref={ref} />);
1548 });
1549 - }).toThrow('Maximum');
1549 + }).rejects.toThrow('Maximum');
1550
1551 // Verify that we don't go over the limit if these updates are unrelated.
1552 limit -= 10;
@@ -1566,15 +1566,15 @@ describe('ReactUpdates', () => {
1566 expect(container.textContent).toBe(limit.toString());
1567
1568 limit += 10;
1569 - expect(() => {
1570 - ReactDOM.flushSync(() => {
1569 + await expect(async () => {
1570 + await act(() => {
1571 ref.current.setState({step: 0});
1572 });
1573 - }).toThrow('Maximum');
1573 + }).rejects.toThrow('Maximum');
1574 expect(ref.current).toBe(null);
1575 });
1576
1577 - it('does not fall into an infinite update loop', () => {
1577 + it('does not fall into an infinite update loop', async () => {
1578 class NonTerminating extends React.Component {
1579 state = {step: 0};
1580
@@ -1599,14 +1599,14 @@ describe('ReactUpdates', () => {
1599 const container = document.createElement('div');
1600 const root = ReactDOMClient.createRoot(container);
1601
1602 - expect(() => {
1603 - ReactDOM.flushSync(() => {
1602 + await expect(async () => {
1603 + await act(() => {
1604 root.render(<NonTerminating />);
1605 });
1606 - }).toThrow('Maximum');
1606 + }).rejects.toThrow('Maximum');
1607 });
1608
1609 - it('does not fall into an infinite update loop with useLayoutEffect', () => {
1609 + it('does not fall into an infinite update loop with useLayoutEffect', async () => {
1610 function NonTerminating() {
1611 const [step, setStep] = React.useState(0);
1612 React.useLayoutEffect(() => {
@@ -1617,11 +1617,11 @@ describe('ReactUpdates', () => {
1617
1618 const container = document.createElement('div');
1619 const root = ReactDOMClient.createRoot(container);
1620 - expect(() => {
1621 - ReactDOM.flushSync(() => {
1620 + await expect(async () => {
1621 + await act(() => {
1622 root.render(<NonTerminating />);
1623 });
1624 - }).toThrow('Maximum');
1624 + }).rejects.toThrow('Maximum');
1625 });
1626
1627 it('can recover after falling into an infinite update loop', async () => {
@@ -1650,29 +1650,29 @@ describe('ReactUpdates', () => {
1650
1651 const container = document.createElement('div');
1652 const root = ReactDOMClient.createRoot(container);
1653 - expect(() => {
1654 - ReactDOM.flushSync(() => {
1653 + await expect(async () => {
1654 + await act(() => {
1655 root.render(<NonTerminating />);
1656 });
1657 - }).toThrow('Maximum');
1657 + }).rejects.toThrow('Maximum');
1658
1659 await act(() => {
1660 root.render(<Terminating />);
1661 });
1662 expect(container.textContent).toBe('1');
1663
1664 - expect(() => {
1665 - ReactDOM.flushSync(() => {
1664 + await expect(async () => {
1665 + await act(() => {
1666 root.render(<NonTerminating />);
1667 });
1668 - }).toThrow('Maximum');
1668 + }).rejects.toThrow('Maximum');
1669 await act(() => {
1670 root.render(<Terminating />);
1671 });
1672 expect(container.textContent).toBe('1');
1673 });
1674
1675 - it('does not fall into mutually recursive infinite update loop with same container', () => {
1675 + it('does not fall into mutually recursive infinite update loop with same container', async () => {
1676 // Note: this test would fail if there were two or more different roots.
1677 const container = document.createElement('div');
1678 const root = ReactDOMClient.createRoot(container);
@@ -1694,14 +1694,14 @@ describe('ReactUpdates', () => {
1694 }
1695 }
1696
1697 - expect(() => {
1698 - ReactDOM.flushSync(() => {
1697 + await expect(async () => {
1698 + await act(() => {
1699 root.render(<A />);
1700 });
1701 - }).toThrow('Maximum');
1701 + }).rejects.toThrow('Maximum');
1702 });
1703
1704 - it('does not fall into an infinite error loop', () => {
1704 + it('does not fall into an infinite error loop', async () => {
1705 function BadRender() {
1706 throw new Error('error');
1707 }
@@ -1730,11 +1730,11 @@ describe('ReactUpdates', () => {
1730
1731 const container = document.createElement('div');
1732 const root = ReactDOMClient.createRoot(container);
1733 - expect(() => {
1734 - ReactDOM.flushSync(() => {
1733 + await expect(async () => {
1734 + await act(() => {
1735 root.render(<NonTerminating />);
1736 });
1737 - }).toThrow('Maximum');
1737 + }).rejects.toThrow('Maximum');
1738 });
1739
1740 it('can schedule ridiculously many updates within the same batch without triggering a maximum update error', async () => {
@@ -1775,7 +1775,7 @@ describe('ReactUpdates', () => {
1775 expect(subscribers.length).toBe(limit);
1776 });
1777
1778 - it("does not infinite loop if there's a synchronous render phase update on another component", () => {
1778 + it("does not infinite loop if there's a synchronous render phase update on another component", async () => {
1779 if (gate(flags => !flags.enableInfiniteRenderLoopDetection)) {
1780 return;
1781 }
@@ -1795,10 +1795,10 @@ describe('ReactUpdates', () => {
1795 const container = document.createElement('div');
1796 const root = ReactDOMClient.createRoot(container);
1797
1798 - expect(() => {
1799 - expect(() => ReactDOM.flushSync(() => root.render(<App />))).toThrow(
1800 - 'Maximum update depth exceeded',
1801 - );
1798 + await expect(async () => {
1799 + await expect(async () => {
1800 + await act(() => ReactDOM.flushSync(() => root.render(<App />)));
1801 + }).rejects.toThrow('Maximum update depth exceeded');
1802 }).toErrorDev(
1803 'Warning: Cannot update a component (`App`) while rendering a different component (`Child`)',
1804 );
@@ -1926,7 +1926,7 @@ describe('ReactUpdates', () => {
1926 });
1927 }
1928
1929 - it('prevents infinite update loop triggered by synchronous updates in useEffect', () => {
1929 + it('prevents infinite update loop triggered by synchronous updates in useEffect', async () => {
1930 // Ignore flushSync warning
1931 spyOnDev(console, 'error').mockImplementation(() => {});
1932
@@ -1950,10 +1950,12 @@ describe('ReactUpdates', () => {
1950
1951 const container = document.createElement('div');
1952 const root = ReactDOMClient.createRoot(container);
1953 - expect(() => {
1954 - ReactDOM.flushSync(() => {
1955 - root.render(<NonTerminating />);
1953 + await expect(async () => {
1954 + await act(() => {
1955 + ReactDOM.flushSync(() => {
1956 + root.render(<NonTerminating />);
1957 + });
1958 });
1957 - }).toThrow('Maximum update depth exceeded');
1959 + }).rejects.toThrow('Maximum update depth exceeded');
1960 });
1961 });
packages/react-dom/src/client/ReactDOMRoot.js
+5 -11
@@ -70,17 +70,11 @@ import {
70 } from 'react-reconciler/src/ReactFiberReconciler';
71 import {ConcurrentRoot} from 'react-reconciler/src/ReactRootTags';
72
73 -/* global reportError */
74 -const defaultOnRecoverableError =
75 - typeof reportError === 'function'
76 - ? // In modern browsers, reportError will dispatch an error event,
77 - // emulating an uncaught JavaScript error.
78 - reportError
79 - : (error: mixed) => {
80 - // In older browsers and test environments, fallback to console.error.
81 - // eslint-disable-next-line react-internal/no-production-logging
82 - console['error'](error);
83 - };
73 +import reportGlobalError from 'shared/reportGlobalError';
74 +
75 +function defaultOnRecoverableError(error: mixed, errorInfo: any) {
76 + reportGlobalError(error);
77 +}
78
79 // $FlowFixMe[missing-this-annot]
80 function ReactDOMRoot(internalRoot: FiberRoot) {
packages/react-native-renderer/src/__tests__/ReactNativeEvents-test.internal.js
+9 -7
@@ -13,6 +13,7 @@
13 let PropTypes;
14 let RCTEventEmitter;
15 let React;
16 +let act;
17 let ReactNative;
18 let ResponderEventPlugin;
19 let UIManager;
@@ -67,6 +68,7 @@ beforeEach(() => {
68 RCTEventEmitter =
69 require('react-native/Libraries/ReactPrivate/ReactNativePrivateInterface').RCTEventEmitter;
70 React = require('react');
71 + act = require('internal-test-utils').act;
72 ReactNative = require('react-native-renderer');
73 ResponderEventPlugin =
74 require('react-native-renderer/src/legacy-events/ResponderEventPlugin').default;
@@ -77,7 +79,7 @@ beforeEach(() => {
79 .ReactNativeViewConfigRegistry.register;
80 });
81
80 -it('fails to register the same event name with different types', () => {
82 +it('fails to register the same event name with different types', async () => {
83 const InvalidEvents = createReactNativeComponentClass('InvalidEvents', () => {
84 if (!__DEV__) {
85 // Simulate a registration error in prod.
@@ -109,15 +111,15 @@ it('fails to register the same event name with different types', () => {
111
112 // The first time this renders,
113 // we attempt to register the view config and fail.
112 - expect(() => ReactNative.render(<InvalidEvents />, 1)).toThrow(
113 - 'Event cannot be both direct and bubbling: topChange',
114 - );
114 + await expect(
115 + async () => await act(() => ReactNative.render(<InvalidEvents />, 1)),
116 + ).rejects.toThrow('Event cannot be both direct and bubbling: topChange');
117
118 // Continue to re-register the config and
119 // fail so that we don't mask the above failure.
118 - expect(() => ReactNative.render(<InvalidEvents />, 1)).toThrow(
119 - 'Event cannot be both direct and bubbling: topChange',
120 - );
120 + await expect(
121 + async () => await act(() => ReactNative.render(<InvalidEvents />, 1)),
122 + ).rejects.toThrow('Event cannot be both direct and bubbling: topChange');
123 });
124
125 it('fails if unknown/unsupported event types are dispatched', () => {
packages/react-native-renderer/src/__tests__/ReactNativeMount-test.internal.js
+18 -10
@@ -17,6 +17,7 @@ let createReactNativeComponentClass;
17 let UIManager;
18 let TextInputState;
19 let ReactNativePrivateInterface;
20 +let act;
21
22 const DISPATCH_COMMAND_REQUIRES_HOST_COMPONENT =
23 "Warning: dispatchCommand was called with a ref that isn't a " +
@@ -31,6 +32,7 @@ describe('ReactNative', () => {
32 jest.resetModules();
33
34 React = require('react');
35 + act = require('internal-test-utils').act;
36 StrictMode = React.StrictMode;
37 ReactNative = require('react-native-renderer');
38 ReactNativePrivateInterface = require('react-native/Libraries/ReactPrivate/ReactNativePrivateInterface');
@@ -476,7 +478,7 @@ describe('ReactNative', () => {
478 );
479 });
480
479 - it('should throw for text not inside of a <Text> ancestor', () => {
481 + it('should throw for text not inside of a <Text> ancestor', async () => {
482 const ScrollView = createReactNativeComponentClass('RCTScrollView', () => ({
483 validAttributes: {},
484 uiViewClassName: 'RCTScrollView',
@@ -490,18 +492,24 @@ describe('ReactNative', () => {
492 uiViewClassName: 'RCTView',
493 }));
494
493 - expect(() => ReactNative.render(<View>this should warn</View>, 11)).toThrow(
495 + await expect(async () => {
496 + await act(() => ReactNative.render(<View>this should warn</View>, 11));
497 + }).rejects.toThrow(
498 'Text strings must be rendered within a <Text> component.',
499 );
500
497 - expect(() =>
498 - ReactNative.render(
499 - <Text>
500 - <ScrollView>hi hello hi</ScrollView>
501 - </Text>,
502 - 11,
503 - ),
504 - ).toThrow('Text strings must be rendered within a <Text> component.');
501 + await expect(async () => {
502 + await act(() =>
503 + ReactNative.render(
504 + <Text>
505 + <ScrollView>hi hello hi</ScrollView>
506 + </Text>,
507 + 11,
508 + ),
509 + );
510 + }).rejects.toThrow(
511 + 'Text strings must be rendered within a <Text> component.',
512 + );
513 });
514
515 it('should not throw for text inside of an indirect <Text> ancestor', () => {
packages/react-reconciler/src/ReactFiberErrorLogger.js
+64 -33
@@ -14,6 +14,11 @@ import {showErrorDialog} from './ReactFiberErrorDialog';
14 import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
15 import {HostRoot} from 'react-reconciler/src/ReactWorkTags';
16
17 +import reportGlobalError from 'shared/reportGlobalError';
18 +
19 +import ReactSharedInternals from 'shared/ReactSharedInternals';
20 +const {ReactCurrentActQueue} = ReactSharedInternals;
21 +
22 export function logCapturedError(
23 boundary: Fiber,
24 errorInfo: CapturedValue<mixed>,
@@ -28,46 +33,72 @@ export function logCapturedError(
33 }
34
35 const error = (errorInfo.value: any);
31 - if (__DEV__) {
32 - const source = errorInfo.source;
33 - const stack = errorInfo.stack;
34 - const componentStack = stack !== null ? stack : '';
35 - // TODO: There's no longer a way to silence these warnings e.g. for tests.
36 - // See https://github.com/facebook/react/pull/13384
36
38 - const componentName = source ? getComponentNameFromFiber(source) : null;
39 - const componentNameMessage = componentName
40 - ? `The above error occurred in the <${componentName}> component:`
41 - : 'The above error occurred in one of your React components:';
37 + if (boundary.tag === HostRoot) {
38 + if (__DEV__ && ReactCurrentActQueue.current !== null) {
39 + // For uncaught errors inside act, we track them on the act and then
40 + // rethrow them into the test.
41 + ReactCurrentActQueue.thrownErrors.push(error);
42 + return;
43 + }
44 + // For uncaught root errors we report them as uncaught to the browser's
45 + // onerror callback. This won't have component stacks and the error addendum.
46 + // So we add those into a separate console.warn.
47 + reportGlobalError(error);
48 + if (__DEV__) {
49 + const source = errorInfo.source;
50 + const stack = errorInfo.stack;
51 + const componentStack = stack !== null ? stack : '';
52 + // TODO: There's no longer a way to silence these warnings e.g. for tests.
53 + // See https://github.com/facebook/react/pull/13384
54 +
55 + const componentName = source ? getComponentNameFromFiber(source) : null;
56 + const componentNameMessage = componentName
57 + ? `An error occurred in the <${componentName}> component:`
58 + : 'An error occurred in one of your React components:';
59
43 - let errorBoundaryMessage;
44 - if (boundary.tag === HostRoot) {
45 - errorBoundaryMessage =
60 + console['warn'](
61 + '%s\n%s\n\n%s',
62 + componentNameMessage,
63 + componentStack,
64 'Consider adding an error boundary to your tree to customize error handling behavior.\n' +
47 - 'Visit https://react.dev/link/error-boundaries to learn more about error boundaries.';
48 - } else {
65 + 'Visit https://react.dev/link/error-boundaries to learn more about error boundaries.',
66 + );
67 + }
68 + } else {
69 + // Caught by error boundary
70 + if (__DEV__) {
71 + const source = errorInfo.source;
72 + const stack = errorInfo.stack;
73 + const componentStack = stack !== null ? stack : '';
74 + // TODO: There's no longer a way to silence these warnings e.g. for tests.
75 + // See https://github.com/facebook/react/pull/13384
76 +
77 + const componentName = source ? getComponentNameFromFiber(source) : null;
78 + const componentNameMessage = componentName
79 + ? `The above error occurred in the <${componentName}> component:`
80 + : 'The above error occurred in one of your React components:';
81 +
82 const errorBoundaryName =
83 getComponentNameFromFiber(boundary) || 'Anonymous';
51 - errorBoundaryMessage =
52 - `React will try to recreate this component tree from scratch ` +
53 - `using the error boundary you provided, ${errorBoundaryName}.`;
54 - }
84
56 - // In development, we provide our own message which includes the component stack
57 - // in addition to the error.
58 - console['error'](
85 + // In development, we provide our own message which includes the component stack
86 + // in addition to the error.
87 // Don't transform to our wrapper
60 - '%o\n\n%s\n%s\n\n%s',
61 - error,
62 - componentNameMessage,
63 - componentStack,
64 - errorBoundaryMessage,
65 - );
66 - } else {
67 - // In production, we print the error directly.
68 - // This will include the message, the JS stack, and anything the browser wants to show.
69 - // We pass the error object instead of custom message so that the browser displays the error natively.
70 - console['error'](error); // Don't transform to our wrapper
88 + console['error'](
89 + '%o\n\n%s\n%s\n\n%s',
90 + error,
91 + componentNameMessage,
92 + componentStack,
93 + `React will try to recreate this component tree from scratch ` +
94 + `using the error boundary you provided, ${errorBoundaryName}.`,
95 + );
96 + } else {
97 + // In production, we print the error directly.
98 + // This will include the message, the JS stack, and anything the browser wants to show.
99 + // We pass the error object instead of custom message so that the browser displays the error natively.
100 + console['error'](error); // Don't transform to our wrapper
101 + }
102 }
103 } catch (e) {
104 // This method must not throw, or React internal state will get messed up.
packages/react-reconciler/src/ReactFiberRootScheduler.js
+2 -37
@@ -166,7 +166,6 @@ function flushSyncWorkAcrossRoots_impl(onlyLegacy: boolean) {
166
167 // There may or may not be synchronous work scheduled. Let's check.
168 let didPerformSomeWork;
169 - let errors: Array<mixed> | null = null;
169 isFlushingWork = true;
170 do {
171 didPerformSomeWork = false;
@@ -184,48 +183,14 @@ function flushSyncWorkAcrossRoots_impl(onlyLegacy: boolean) {
183 );
184 if (includesSyncLane(nextLanes)) {
185 // This root has pending sync work. Flush it now.
187 - try {
188 - didPerformSomeWork = true;
189 - performSyncWorkOnRoot(root, nextLanes);
190 - } catch (error) {
191 - // Collect errors so we can rethrow them at the end
192 - if (errors === null) {
193 - errors = [error];
194 - } else {
195 - errors.push(error);
196 - }
197 - }
186 + didPerformSomeWork = true;
187 + performSyncWorkOnRoot(root, nextLanes);
188 }
189 }
190 root = root.next;
191 }
192 } while (didPerformSomeWork);
193 isFlushingWork = false;
204 -
205 - // If any errors were thrown, rethrow them right before exiting.
206 - // TODO: Consider returning these to the caller, to allow them to decide
207 - // how/when to rethrow.
208 - if (errors !== null) {
209 - if (errors.length > 1) {
210 - if (typeof AggregateError === 'function') {
211 - // eslint-disable-next-line no-undef
212 - throw new AggregateError(errors);
213 - } else {
214 - for (let i = 1; i < errors.length; i++) {
215 - scheduleImmediateTask(throwError.bind(null, errors[i]));
216 - }
217 - const firstError = errors[0];
218 - throw firstError;
219 - }
220 - } else {
221 - const error = errors[0];
222 - throw error;
223 - }
224 - }
225 -}
226 -
227 -function throwError(error: mixed) {
228 - throw error;
194 }
195
196 function processRootScheduleInMicrotask() {
packages/react-reconciler/src/ReactFiberThrow.js
-3
@@ -59,7 +59,6 @@ import {
59 import {
60 renderDidError,
61 renderDidSuspendDelayIfPossible,
62 - onUncaughtError,
62 markLegacyErrorBoundaryAsFailed,
63 isAlreadyFailedLegacyErrorBoundary,
64 attachPingListener,
@@ -96,9 +95,7 @@ function createRootErrorUpdate(
95 // Caution: React DevTools currently depends on this property
96 // being called "element".
97 update.payload = {element: null};
99 - const error = errorInfo.value;
98 update.callback = () => {
101 - onUncaughtError(error);
99 logCapturedError(fiber, errorInfo);
100 };
101 return update;
packages/react-reconciler/src/ReactFiberWorkLoop.js
+14 -30
@@ -277,6 +277,7 @@ import {
277 } from './ReactFiberRootScheduler';
278 import {getMaskedContext, getUnmaskedContext} from './ReactFiberContext';
279 import {peekEntangledActionLane} from './ReactFiberAsyncAction';
280 +import {logCapturedError} from './ReactFiberErrorLogger';
281
282 const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
283
@@ -348,8 +349,6 @@ export let entangledRenderLanes: Lanes = NoLanes;
349
350 // Whether to root completed, errored, suspended, etc.
351 let workInProgressRootExitStatus: RootExitStatus = RootInProgress;
351 -// A fatal error, if one is thrown
352 -let workInProgressRootFatalError: mixed = null;
352 // The work left over by components that were visited during this render. Only
353 // includes unprocessed updates, not work in bailed out children.
354 let workInProgressRootSkippedLanes: Lanes = NoLanes;
@@ -564,8 +563,6 @@ export function getRenderTargetTime(): number {
563 return workInProgressRootRenderTargetTime;
564 }
565
567 -let hasUncaughtError = false;
568 -let firstUncaughtError = null;
566 let legacyErrorBoundariesThatAlreadyFailed: Set<mixed> | null = null;
567
568 let rootDoesHavePassiveEffects: boolean = false;
@@ -974,11 +971,9 @@ export function performConcurrentWorkOnRoot(
971 }
972 }
973 if (exitStatus === RootFatalErrored) {
977 - const fatalError = workInProgressRootFatalError;
974 prepareFreshStack(root, NoLanes);
975 markRootSuspended(root, lanes, NoLane);
980 - ensureRootIsScheduled(root);
981 - throw fatalError;
976 + break;
977 }
978
979 // 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 {
1386 }
1387
1388 if (exitStatus === RootFatalErrored) {
1394 - const fatalError = workInProgressRootFatalError;
1389 prepareFreshStack(root, NoLanes);
1390 markRootSuspended(root, lanes, NoLane);
1391 ensureRootIsScheduled(root);
1398 - throw fatalError;
1392 + return null;
1393 }
1394
1395 if (exitStatus === RootDidNotComplete) {
@@ -1625,7 +1619,6 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
1619 workInProgressThrownValue = null;
1620 workInProgressRootDidAttachPingListener = false;
1621 workInProgressRootExitStatus = RootInProgress;
1628 - workInProgressRootFatalError = null;
1622 workInProgressRootSkippedLanes = NoLanes;
1623 workInProgressRootInterleavedUpdatedLanes = NoLanes;
1624 workInProgressRootRenderPhaseUpdatedLanes = NoLanes;
@@ -1738,7 +1731,10 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
1731 if (erroredWork === null) {
1732 // This is a fatal error
1733 workInProgressRootExitStatus = RootFatalErrored;
1741 - workInProgressRootFatalError = thrownValue;
1734 + logCapturedError(
1735 + root.current,
1736 + createCapturedValueAtFiber(thrownValue, root.current),
1737 + );
1738 return;
1739 }
1740
@@ -2516,7 +2512,7 @@ function throwAndUnwindWorkLoop(
2512 workInProgressRootRenderLanes,
2513 );
2514 if (didFatal) {
2519 - panicOnRootError(thrownValue);
2515 + panicOnRootError(root, thrownValue);
2516 return;
2517 }
2518 } catch (error) {
@@ -2528,7 +2524,7 @@ function throwAndUnwindWorkLoop(
2524 workInProgress = returnFiber;
2525 throw error;
2526 } else {
2531 - panicOnRootError(thrownValue);
2527 + panicOnRootError(root, thrownValue);
2528 return;
2529 }
2530 }
@@ -2550,13 +2546,16 @@ function throwAndUnwindWorkLoop(
2546 }
2547 }
2548
2553 -function panicOnRootError(error: mixed) {
2549 +function panicOnRootError(root: FiberRoot, error: mixed) {
2550 // There's no ancestor that can handle this exception. This should never
2551 // happen because the root is supposed to capture all errors that weren't
2552 // caught by an error boundary. This is a fatal error, or panic condition,
2553 // because we've run out of ways to recover.
2554 workInProgressRootExitStatus = RootFatalErrored;
2559 - workInProgressRootFatalError = error;
2555 + logCapturedError(
2556 + root.current,
2557 + createCapturedValueAtFiber(error, root.current),
2558 + );
2559 // Set `workInProgress` to null. This represents advancing to the next
2560 // sibling, or the parent if there are no siblings. But since the root
2561 // has no siblings nor a parent, we set it to null. Usually this is
@@ -3032,13 +3031,6 @@ function commitRootImpl(
3031 }
3032 }
3033
3035 - if (hasUncaughtError) {
3036 - hasUncaughtError = false;
3037 - const error = firstUncaughtError;
3038 - firstUncaughtError = null;
3039 - throw error;
3040 - }
3041 -
3034 // If the passive effects are the result of a discrete render, flush them
3035 // synchronously at the end of the current task so that the result is
3036 // immediately observable. Otherwise, we assume that they are not
@@ -3358,14 +3350,6 @@ export function markLegacyErrorBoundaryAsFailed(instance: mixed) {
3350 }
3351 }
3352
3361 -function prepareToThrowUncaughtError(error: mixed) {
3362 - if (!hasUncaughtError) {
3363 - hasUncaughtError = true;
3364 - firstUncaughtError = error;
3365 - }
3366 -}
3367 -export const onUncaughtError = prepareToThrowUncaughtError;
3368 -
3353 function captureCommitPhaseErrorOnRoot(
3354 rootFiber: Fiber,
3355 sourceFiber: Fiber,
packages/react-reconciler/src/__tests__/ReactFlushSync-test.js
+6 -4
@@ -326,10 +326,12 @@ describe('ReactFlushSync', () => {
326
327 let error;
328 try {
329 - ReactDOM.flushSync(() => {
330 - root1.render(<Throws error={aahh} />);
331 - root2.render(<Throws error={nooo} />);
332 - root3.render(<Text text="aww" />);
329 + await act(() => {
330 + ReactDOM.flushSync(() => {
331 + root1.render(<Throws error={aahh} />);
332 + root2.render(<Throws error={nooo} />);
333 + root3.render(<Text text="aww" />);
334 + });
335 });
336 } catch (e) {
337 error = e;
packages/react-reconciler/src/__tests__/ReactFlushSyncNoAggregateError-test.js
+7 -5
@@ -119,10 +119,12 @@ describe('ReactFlushSync (AggregateError not available)', () => {
119 overrideQueueMicrotask = true;
120 let error;
121 try {
122 - ReactDOM.flushSync(() => {
123 - root1.render(<Throws error={aahh} />);
124 - root2.render(<Throws error={nooo} />);
125 - root3.render(<Text text="aww" />);
122 + await act(() => {
123 + ReactDOM.flushSync(() => {
124 + root1.render(<Throws error={aahh} />);
125 + root2.render(<Throws error={nooo} />);
126 + root3.render(<Text text="aww" />);
127 + });
128 });
129 } catch (e) {
130 error = e;
@@ -140,6 +142,6 @@ describe('ReactFlushSync (AggregateError not available)', () => {
142 // AggregateError is not available, React throws the first error, then
143 // throws the remaining errors in separate tasks.
144 expect(error).toBe(aahh);
143 - expect(flushFakeMicrotasks).toThrow(nooo);
145 + await flushFakeMicrotasks();
146 });
147 });
packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js
+1 -1
@@ -1899,7 +1899,7 @@ describe('ReactHooks', () => {
1899 }).rejects.toThrow('Hello');
1900
1901 if (__DEV__) {
1902 - expect(console.error).toHaveBeenCalledTimes(2);
1902 + expect(console.error).toHaveBeenCalledTimes(1);
1903 expect(console.error.mock.calls[0][0]).toContain(
1904 'Warning: Cannot update a component (`%s`) while rendering ' +
1905 'a different component (`%s`).',
packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js
+59 -45
@@ -2077,14 +2077,15 @@ describe('ReactHooksWithNoopRenderer', () => {
2077 });
2078 return <Text text={'Count: ' + props.count} />;
2079 }
2080 - await act(async () => {
2081 - ReactNoop.render(<Counter count={0} />, () =>
2082 - Scheduler.log('Sync effect'),
2083 - );
2084 - await waitFor(['Count: 0', 'Sync effect']);
2085 - expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
2086 - expect(() => ReactNoop.flushPassiveEffects()).toThrow('Oops');
2087 - });
2080 + await expect(async () => {
2081 + await act(async () => {
2082 + ReactNoop.render(<Counter count={0} />, () =>
2083 + Scheduler.log('Sync effect'),
2084 + );
2085 + await waitFor(['Count: 0', 'Sync effect']);
2086 + expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
2087 + });
2088 + }).rejects.toThrow('Oops');
2089
2090 assertLog([
2091 'Mount A [0]',
@@ -2107,7 +2108,7 @@ describe('ReactHooksWithNoopRenderer', () => {
2108 useEffect(() => {
2109 if (props.count === 1) {
2110 Scheduler.log('Oops!');
2110 - throw new Error('Oops!');
2111 + throw new Error('Oops error!');
2112 }
2113 Scheduler.log(`Mount B [${props.count}]`);
2114 return () => {
@@ -2126,22 +2127,27 @@ describe('ReactHooksWithNoopRenderer', () => {
2127 assertLog(['Mount A [0]', 'Mount B [0]']);
2128 });
2129
2129 - await act(async () => {
2130 - // This update will trigger an error
2131 - ReactNoop.render(<Counter count={1} />, () =>
2132 - Scheduler.log('Sync effect'),
2133 - );
2134 - await waitFor(['Count: 1', 'Sync effect']);
2135 - expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
2136 - expect(() => ReactNoop.flushPassiveEffects()).toThrow('Oops');
2137 - assertLog(['Unmount A [0]', 'Unmount B [0]', 'Mount A [1]', 'Oops!']);
2138 - expect(ReactNoop).toMatchRenderedOutput(null);
2139 - });
2140 - assertLog([
2141 - // Clean up effect A runs passively on unmount.
2142 - // There's no effect B to clean-up, because it never mounted.
2143 - 'Unmount A [1]',
2144 - ]);
2130 + await expect(async () => {
2131 + await act(async () => {
2132 + // This update will trigger an error
2133 + ReactNoop.render(<Counter count={1} />, () =>
2134 + Scheduler.log('Sync effect'),
2135 + );
2136 + await waitFor(['Count: 1', 'Sync effect']);
2137 + expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
2138 + ReactNoop.flushPassiveEffects();
2139 + assertLog([
2140 + 'Unmount A [0]',
2141 + 'Unmount B [0]',
2142 + 'Mount A [1]',
2143 + 'Oops!',
2144 + // Clean up effect A runs passively on unmount.
2145 + // There's no effect B to clean-up, because it never mounted.
2146 + 'Unmount A [1]',
2147 + ]);
2148 + expect(ReactNoop).toMatchRenderedOutput(null);
2149 + });
2150 + }).rejects.toThrow('Oops error!');
2151 });
2152
2153 it('handles errors in destroy on update', async () => {
@@ -2151,7 +2157,7 @@ describe('ReactHooksWithNoopRenderer', () => {
2157 return () => {
2158 Scheduler.log('Oops!');
2159 if (props.count === 0) {
2154 - throw new Error('Oops!');
2160 + throw new Error('Oops error!');
2161 }
2162 };
2163 });
@@ -2174,26 +2180,34 @@ describe('ReactHooksWithNoopRenderer', () => {
2180 assertLog(['Mount A [0]', 'Mount B [0]']);
2181 });
2182
2177 - await act(async () => {
2178 - // This update will trigger an error during passive effect unmount
2179 - ReactNoop.render(<Counter count={1} />, () =>
2180 - Scheduler.log('Sync effect'),
2181 - );
2182 - await waitFor(['Count: 1', 'Sync effect']);
2183 - expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
2184 - expect(() => ReactNoop.flushPassiveEffects()).toThrow('Oops');
2183 + await expect(async () => {
2184 + await act(async () => {
2185 + // This update will trigger an error during passive effect unmount
2186 + ReactNoop.render(<Counter count={1} />, () =>
2187 + Scheduler.log('Sync effect'),
2188 + );
2189 + await waitFor(['Count: 1', 'Sync effect']);
2190 + expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
2191 + ReactNoop.flushPassiveEffects();
2192
2186 - // This branch enables a feature flag that flushes all passive destroys in a
2187 - // separate pass before flushing any passive creates.
2188 - // A result of this two-pass flush is that an error thrown from unmount does
2189 - // not block the subsequent create functions from being run.
2190 - assertLog(['Oops!', 'Unmount B [0]', 'Mount A [1]', 'Mount B [1]']);
2191 - });
2193 + // This branch enables a feature flag that flushes all passive destroys in a
2194 + // separate pass before flushing any passive creates.
2195 + // A result of this two-pass flush is that an error thrown from unmount does
2196 + // not block the subsequent create functions from being run.
2197 + assertLog([
2198 + 'Oops!',
2199 + 'Unmount B [0]',
2200 + 'Mount A [1]',
2201 + 'Mount B [1]',
2202 + // <Counter> gets unmounted because an error is thrown above.
2203 + // The remaining destroy functions are run later on unmount, since they're passive.
2204 + // In this case, one of them throws again (because of how the test is written).
2205 + 'Oops!',
2206 + 'Unmount B [1]',
2207 + ]);
2208 + });
2209 + }).rejects.toThrow('Oops error!');
2210
2193 - // <Counter> gets unmounted because an error is thrown above.
2194 - // The remaining destroy functions are run later on unmount, since they're passive.
2195 - // In this case, one of them throws again (because of how the test is written).
2196 - assertLog(['Oops!', 'Unmount B [1]']);
2211 expect(ReactNoop).toMatchRenderedOutput(null);
2212 });
2213
@@ -3805,7 +3819,7 @@ describe('ReactHooksWithNoopRenderer', () => {
3819 await waitForThrow(
3820 'Rendered more hooks than during the previous render.',
3821 );
3808 - assertLog([]);
3822 + assertLog(['Unmount A']);
3823 }).toErrorDev([
3824 'Warning: React has detected a change in the order of Hooks called by App. ' +
3825 'This will lead to bugs and errors if not fixed. For more information, ' +
packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js
+37 -35
@@ -387,7 +387,7 @@ describe('ReactIncrementalErrorHandling', () => {
387 // The work loop unwound to the nearest error boundary. React will try
388 // to render one more time, synchronously. Flush just one unit of work to
389 // demonstrate that this render is synchronous.
390 - expect(() => Scheduler.unstable_flushNumberOfYields(1)).toThrow('oops');
390 + Scheduler.unstable_flushNumberOfYields(1);
391 assertLog(['Parent', 'BadRender', 'commit']);
392 expect(ReactNoop).toMatchRenderedOutput(null);
393 });
@@ -425,10 +425,8 @@ describe('ReactIncrementalErrorHandling', () => {
425 // Expire the render midway through
426 Scheduler.unstable_advanceTime(10000);
427
428 - expect(() => {
429 - Scheduler.unstable_flushExpired();
430 - ReactNoop.flushSync();
431 - }).toThrow('Oops');
428 + Scheduler.unstable_flushExpired();
429 + ReactNoop.flushSync();
430
431 assertLog([
432 // The render expired, but we shouldn't throw out the partial work.
@@ -769,15 +767,14 @@ describe('ReactIncrementalErrorHandling', () => {
767 throw new Error('Hello');
768 }
769
772 - expect(() => {
773 - ReactNoop.flushSync(() => {
774 - ReactNoop.render(
775 - <RethrowErrorBoundary>
776 - <BrokenRender />
777 - </RethrowErrorBoundary>,
778 - );
779 - });
780 - }).toThrow('Hello');
770 + ReactNoop.flushSync(() => {
771 + ReactNoop.render(
772 + <RethrowErrorBoundary>
773 + <BrokenRender />
774 + </RethrowErrorBoundary>,
775 + );
776 + });
777 +
778 assertLog([
779 'RethrowErrorBoundary render',
780 'BrokenRender',
@@ -809,18 +806,17 @@ describe('ReactIncrementalErrorHandling', () => {
806 throw new Error('Hello');
807 }
808
812 - expect(() => {
813 - ReactNoop.flushSync(() => {
814 - ReactNoop.render(
815 - <RethrowErrorBoundary>Before the storm.</RethrowErrorBoundary>,
816 - );
817 - ReactNoop.render(
818 - <RethrowErrorBoundary>
819 - <BrokenRender />
820 - </RethrowErrorBoundary>,
821 - );
822 - });
823 - }).toThrow('Hello');
809 + ReactNoop.flushSync(() => {
810 + ReactNoop.render(
811 + <RethrowErrorBoundary>Before the storm.</RethrowErrorBoundary>,
812 + );
813 + ReactNoop.render(
814 + <RethrowErrorBoundary>
815 + <BrokenRender />
816 + </RethrowErrorBoundary>,
817 + );
818 + });
819 +
820 assertLog([
821 'RethrowErrorBoundary render',
822 'BrokenRender',
@@ -1120,14 +1116,15 @@ describe('ReactIncrementalErrorHandling', () => {
1116 expect(ReactNoop.getChildrenAsJSX('e')).toEqual(null);
1117
1118 ReactNoop.renderToRootWithID(<BrokenRender label="a" />, 'a');
1119 + await waitForThrow('a');
1120 +
1121 ReactNoop.renderToRootWithID(<span prop="b:6" />, 'b');
1122 ReactNoop.renderToRootWithID(<BrokenRender label="c" />, 'c');
1123 + await waitForThrow('c');
1124 +
1125 ReactNoop.renderToRootWithID(<span prop="d:6" />, 'd');
1126 ReactNoop.renderToRootWithID(<BrokenRender label="e" />, 'e');
1127 ReactNoop.renderToRootWithID(<span prop="f:6" />, 'f');
1128 -
1129 - await waitForThrow('a');
1130 - await waitForThrow('c');
1128 await waitForThrow('e');
1129
1130 await waitForAll([]);
@@ -1369,8 +1366,10 @@ describe('ReactIncrementalErrorHandling', () => {
1366
1367 let aggregateError;
1368 try {
1372 - ReactNoop.flushSync(() => {
1373 - inst.setState({fail: true});
1369 + await act(() => {
1370 + ReactNoop.flushSync(() => {
1371 + inst.setState({fail: true});
1372 + });
1373 });
1374 } catch (e) {
1375 aggregateError = e;
@@ -1387,9 +1386,10 @@ describe('ReactIncrementalErrorHandling', () => {
1386
1387 // React threw both errors as a single AggregateError
1388 const errors = aggregateError.errors;
1390 - expect(errors.length).toBe(2);
1389 + expect(errors.length).toBe(3);
1390 expect(errors[0].message).toBe('Hello.');
1391 expect(errors[1].message).toBe('One does not simply unmount me.');
1392 + expect(errors[2].message).toBe('One does not simply unmount me.');
1393 });
1394
1395 it('does not interrupt unmounting if detaching a ref throws', async () => {
@@ -1878,6 +1878,7 @@ describe('ReactIncrementalErrorHandling', () => {
1878 // accident) a render phase triggered from userspace.
1879
1880 spyOnDev(console, 'error').mockImplementation(() => {});
1881 + spyOnDev(console, 'warn').mockImplementation(() => {});
1882
1883 let numberOfThrows = 0;
1884
@@ -1916,12 +1917,13 @@ describe('ReactIncrementalErrorHandling', () => {
1917 expect(numberOfThrows < 100).toBe(true);
1918
1919 if (__DEV__) {
1919 - expect(console.error).toHaveBeenCalledTimes(2);
1920 + expect(console.error).toHaveBeenCalledTimes(1);
1921 expect(console.error.mock.calls[0][0]).toContain(
1922 'Cannot update a component (`%s`) while rendering a different component',
1923 );
1923 - expect(console.error.mock.calls[1][2]).toContain(
1924 - 'The above error occurred in the <App> component',
1924 + expect(console.warn).toHaveBeenCalledTimes(1);
1925 + expect(console.warn.mock.calls[0][1]).toContain(
1926 + 'An error occurred in the <App> component',
1927 );
1928 }
1929 });
packages/react-reconciler/src/__tests__/ReactIncrementalErrorLogging-test.js
+76 -52
@@ -14,7 +14,13 @@ let React;
14 let ReactNoop;
15 let Scheduler;
16 let waitForAll;
17 -let waitForThrow;
17 +let uncaughtExceptionMock;
18 +
19 +async function fakeAct(cb) {
20 + // We don't use act/waitForThrow here because we want to observe how errors are reported for real.
21 + await cb();
22 + Scheduler.unstable_flushAll();
23 +}
24
25 describe('ReactIncrementalErrorLogging', () => {
26 beforeEach(() => {
@@ -25,20 +31,28 @@ describe('ReactIncrementalErrorLogging', () => {
31
32 const InternalTestUtils = require('internal-test-utils');
33 waitForAll = InternalTestUtils.waitForAll;
28 - waitForThrow = InternalTestUtils.waitForThrow;
34 });
35
36 // Note: in this test file we won't be using toErrorDev() matchers
37 // because they filter out precisely the messages we want to test for.
38 + let oldConsoleWarn;
39 let oldConsoleError;
40 beforeEach(() => {
41 + oldConsoleWarn = console.warn;
42 oldConsoleError = console.error;
43 + console.warn = jest.fn();
44 console.error = jest.fn();
45 + uncaughtExceptionMock = jest.fn();
46 + process.on('uncaughtException', uncaughtExceptionMock);
47 });
48
49 afterEach(() => {
50 + console.warn = oldConsoleWarn;
51 console.error = oldConsoleError;
52 + process.off('uncaughtException', uncaughtExceptionMock);
53 + oldConsoleWarn = null;
54 oldConsoleError = null;
55 + uncaughtExceptionMock = null;
56 });
57
58 it('should log errors that occur during the begin phase', async () => {
@@ -51,23 +65,27 @@ describe('ReactIncrementalErrorLogging', () => {
65 return <div />;
66 }
67 }
54 - ReactNoop.render(
55 - <div>
56 - <span>
57 - <ErrorThrowingComponent />
58 - </span>
59 - </div>,
68 + await fakeAct(() => {
69 + ReactNoop.render(
70 + <div>
71 + <span>
72 + <ErrorThrowingComponent />
73 + </span>
74 + </div>,
75 + );
76 + });
77 + expect(uncaughtExceptionMock).toHaveBeenCalledTimes(1);
78 + expect(uncaughtExceptionMock).toHaveBeenCalledWith(
79 + expect.objectContaining({
80 + message: 'constructor error',
81 + }),
82 );
61 - await waitForThrow('constructor error');
62 - expect(console.error).toHaveBeenCalledTimes(1);
83 if (__DEV__) {
64 - expect(console.error).toHaveBeenCalledWith(
65 - expect.stringContaining('%o'),
66 - expect.objectContaining({
67 - message: 'constructor error',
68 - }),
84 + expect(console.warn).toHaveBeenCalledTimes(1);
85 + expect(console.warn).toHaveBeenCalledWith(
86 + expect.stringContaining('%s'),
87 expect.stringContaining(
70 - 'The above error occurred in the <ErrorThrowingComponent> component:',
88 + 'An error occurred in the <ErrorThrowingComponent> component:',
89 ),
90 expect.stringMatching(
91 new RegExp(
@@ -81,12 +99,6 @@ describe('ReactIncrementalErrorLogging', () => {
99 'to customize error handling behavior.',
100 ),
101 );
84 - } else {
85 - expect(console.error).toHaveBeenCalledWith(
86 - expect.objectContaining({
87 - message: 'constructor error',
88 - }),
89 - );
102 }
103 });
104
@@ -99,23 +111,27 @@ describe('ReactIncrementalErrorLogging', () => {
111 return <div />;
112 }
113 }
102 - ReactNoop.render(
103 - <div>
104 - <span>
105 - <ErrorThrowingComponent />
106 - </span>
107 - </div>,
114 + await fakeAct(() => {
115 + ReactNoop.render(
116 + <div>
117 + <span>
118 + <ErrorThrowingComponent />
119 + </span>
120 + </div>,
121 + );
122 + });
123 + expect(uncaughtExceptionMock).toHaveBeenCalledTimes(1);
124 + expect(uncaughtExceptionMock).toHaveBeenCalledWith(
125 + expect.objectContaining({
126 + message: 'componentDidMount error',
127 + }),
128 );
109 - await waitForThrow('componentDidMount error');
110 - expect(console.error).toHaveBeenCalledTimes(1);
129 if (__DEV__) {
112 - expect(console.error).toHaveBeenCalledWith(
113 - expect.stringContaining('%o'),
114 - expect.objectContaining({
115 - message: 'componentDidMount error',
116 - }),
130 + expect(console.warn).toHaveBeenCalledTimes(1);
131 + expect(console.warn).toHaveBeenCalledWith(
132 + expect.stringContaining('%s'),
133 expect.stringContaining(
118 - 'The above error occurred in the <ErrorThrowingComponent> component:',
134 + 'An error occurred in the <ErrorThrowingComponent> component:',
135 ),
136 expect.stringMatching(
137 new RegExp(
@@ -129,12 +145,6 @@ describe('ReactIncrementalErrorLogging', () => {
145 'to customize error handling behavior.',
146 ),
147 );
132 - } else {
133 - expect(console.error).toHaveBeenCalledWith(
134 - expect.objectContaining({
135 - message: 'componentDidMount error',
136 - }),
137 - );
148 }
149 });
150
@@ -145,19 +155,32 @@ describe('ReactIncrementalErrorLogging', () => {
155 logCapturedErrorCalls.push(error);
156 throw new Error('logCapturedError error');
157 });
158 +
159 + class ErrorBoundary extends React.Component {
160 + state = {error: null};
161 + componentDidCatch(error) {
162 + this.setState({error});
163 + }
164 + render() {
165 + return this.state.error ? null : this.props.children;
166 + }
167 + }
168 class ErrorThrowingComponent extends React.Component {
169 render() {
170 throw new Error('render error');
171 }
172 }
153 - ReactNoop.render(
154 - <div>
155 - <span>
156 - <ErrorThrowingComponent />
157 - </span>
158 - </div>,
159 - );
160 - await waitForThrow('render error');
173 + await fakeAct(() => {
174 + ReactNoop.render(
175 + <div>
176 + <ErrorBoundary>
177 + <span>
178 + <ErrorThrowingComponent />
179 + </span>
180 + </ErrorBoundary>
181 + </div>,
182 + );
183 + });
184 expect(logCapturedErrorCalls.length).toBe(1);
185 if (__DEV__) {
186 expect(console.error).toHaveBeenCalledWith(
@@ -172,12 +195,13 @@ describe('ReactIncrementalErrorLogging', () => {
195 new RegExp(
196 '\\s+(in|at) ErrorThrowingComponent (.*)\n' +
197 '\\s+(in|at) span(.*)\n' +
198 + '\\s+(in|at) ErrorBoundary(.*)\n' +
199 '\\s+(in|at) div(.*)',
200 ),
201 ),
202 expect.stringContaining(
179 - 'Consider adding an error boundary to your tree ' +
180 - 'to customize error handling behavior.',
203 + 'React will try to recreate this component tree from scratch ' +
204 + 'using the error boundary you provided, ErrorBoundary.',
205 ),
206 );
207 } else {
packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js
+1 -1
@@ -230,7 +230,7 @@ describe('ReactLazy', () => {
230 assertLog(['Loading...']);
231 expect(root).not.toMatchRenderedOutput('Hi');
232 if (__DEV__) {
233 - expect(console.error).toHaveBeenCalledTimes(3);
233 + expect(console.error).toHaveBeenCalledTimes(2);
234 expect(console.error.mock.calls[0][0]).toContain(
235 'Expected the result of a dynamic import() call',
236 );
packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js
+4 -2
@@ -887,9 +887,11 @@ describe('ReactSuspenseWithNoopRenderer', () => {
887 });
888
889 // @gate enableLegacyCache
890 - it('in legacy mode, errors when an update suspends without a Suspense boundary during a sync update', () => {
890 + it('in legacy mode, errors when an update suspends without a Suspense boundary during a sync update', async () => {
891 const root = ReactNoop.createLegacyRoot();
892 - expect(() => root.render(<AsyncText text="Async" />)).toThrow(
892 + await expect(async () => {
893 + await act(() => root.render(<AsyncText text="Async" />));
894 + }).rejects.toThrow(
895 'A component suspended while responding to synchronous input.',
896 );
897 });
packages/react-refresh/src/__tests__/ReactFresh-test.js
+99 -91
@@ -71,7 +71,15 @@ describe('ReactFresh', () => {
71 return Component;
72 }
73
74 - function patch(version) {
74 + async function patch(version) {
75 + const Component = version();
76 + await act(() => {
77 + ReactFreshRuntime.performReactRefresh();
78 + });
79 + return Component;
80 + }
81 +
82 + function patchSync(version) {
83 const Component = version();
84 ReactFreshRuntime.performReactRefresh();
85 return Component;
@@ -124,7 +132,7 @@ describe('ReactFresh', () => {
132 expect(el.textContent).toBe('1');
133
134 // Perform a hot update.
127 - const HelloV2 = patch(() => {
135 + const HelloV2 = await patch(() => {
136 function Hello() {
137 const [val, setVal] = React.useState(0);
138 return (
@@ -217,7 +225,7 @@ describe('ReactFresh', () => {
225 expect(el.textContent).toBe('1');
226
227 // Perform a hot update.
220 - const OuterV2 = patch(() => {
228 + const OuterV2 = await patch(() => {
229 function Hello() {
230 const [val, setVal] = React.useState(0);
231 return (
@@ -348,7 +356,7 @@ describe('ReactFresh', () => {
356 expect(el.textContent).toBe('1');
357
358 // Patch to change the color.
351 - const ParentV2 = patch(() => {
359 + const ParentV2 = await patch(() => {
360 function Hello() {
361 const [val, setVal] = React.useState(0);
362 return (
@@ -435,7 +443,7 @@ describe('ReactFresh', () => {
443 expect(el.textContent).toBe('1');
444
445 // Perform a hot update.
438 - patch(() => {
446 + await patch(() => {
447 function Hello({color}) {
448 const [val, setVal] = React.useState(0);
449 return (
@@ -489,7 +497,7 @@ describe('ReactFresh', () => {
497 expect(el.textContent).toBe('1');
498
499 // Perform a hot update of just the rendering function.
492 - patch(() => {
500 + await patch(() => {
501 function Hello({color}) {
502 const [val, setVal] = React.useState(0);
503 return (
@@ -543,7 +551,7 @@ describe('ReactFresh', () => {
551 expect(el.textContent).toBe('1');
552
553 // Perform a hot update.
546 - const OuterV2 = patch(() => {
554 + const OuterV2 = await patch(() => {
555 function Hello() {
556 const [val, setVal] = React.useState(0);
557 return (
@@ -632,7 +640,7 @@ describe('ReactFresh', () => {
640 expect(el.textContent).toBe('1');
641
642 // Perform a hot update.
635 - const OuterV2 = patch(() => {
643 + const OuterV2 = await patch(() => {
644 function Hello() {
645 const [val, setVal] = React.useState(0);
646 return (
@@ -719,7 +727,7 @@ describe('ReactFresh', () => {
727 expect(el.textContent).toBe('1');
728
729 // Perform a hot update of just the rendering function.
722 - patch(() => {
730 + await patch(() => {
731 function Hello() {
732 const [val, setVal] = React.useState(0);
733 return (
@@ -768,7 +776,7 @@ describe('ReactFresh', () => {
776 expect(el.textContent).toBe('1');
777
778 // Perform a hot update.
771 - const OuterV2 = patch(() => {
779 + const OuterV2 = await patch(() => {
780 function Hello() {
781 const [val, setVal] = React.useState(0);
782 return (
@@ -880,7 +888,7 @@ describe('ReactFresh', () => {
888 expect(el.textContent).toBe('1');
889
890 // Perform a hot update.
883 - const AppV2 = patch(() => {
891 + const AppV2 = await patch(() => {
892 function Hello() {
893 const [val, setVal] = React.useState(0);
894 return (
@@ -1003,7 +1011,7 @@ describe('ReactFresh', () => {
1011 expect(container.textContent).toBe('Loading');
1012
1013 // Perform a hot update.
1006 - patch(() => {
1014 + await patch(() => {
1015 function Hello() {
1016 const [val, setVal] = React.useState(0);
1017 return (
@@ -1033,7 +1041,7 @@ describe('ReactFresh', () => {
1041 expect(el.style.color).toBe('red');
1042
1043 // Test another reload.
1036 - patch(() => {
1044 + await patch(() => {
1045 function Hello() {
1046 const [val, setVal] = React.useState(0);
1047 return (
@@ -1087,7 +1095,7 @@ describe('ReactFresh', () => {
1095 expect(container.textContent).toBe('Loading');
1096
1097 // Perform a hot update.
1090 - patch(() => {
1098 + await patch(() => {
1099 function renderHello() {
1100 const [val, setVal] = React.useState(0);
1101 return (
@@ -1118,7 +1126,7 @@ describe('ReactFresh', () => {
1126 expect(el.style.color).toBe('red');
1127
1128 // Test another reload.
1121 - patch(() => {
1129 + await patch(() => {
1130 function renderHello() {
1131 const [val, setVal] = React.useState(0);
1132 return (
@@ -1173,7 +1181,7 @@ describe('ReactFresh', () => {
1181 expect(container.textContent).toBe('Loading');
1182
1183 // Perform a hot update.
1176 - patch(() => {
1184 + await patch(() => {
1185 function renderHello() {
1186 const [val, setVal] = React.useState(0);
1187 return (
@@ -1204,7 +1212,7 @@ describe('ReactFresh', () => {
1212 expect(el.style.color).toBe('red');
1213
1214 // Test another reload.
1207 - patch(() => {
1215 + await patch(() => {
1216 function renderHello() {
1217 const [val, setVal] = React.useState(0);
1218 return (
@@ -1259,7 +1267,7 @@ describe('ReactFresh', () => {
1267 expect(container.textContent).toBe('Loading');
1268
1269 // Perform a hot update.
1262 - patch(() => {
1270 + await patch(() => {
1271 function renderHello() {
1272 const [val, setVal] = React.useState(0);
1273 return (
@@ -1290,7 +1298,7 @@ describe('ReactFresh', () => {
1298 expect(el.style.color).toBe('red');
1299
1300 // Test another reload.
1293 - patch(() => {
1301 + await patch(() => {
1302 function renderHello() {
1303 const [val, setVal] = React.useState(0);
1304 return (
@@ -1358,7 +1366,7 @@ describe('ReactFresh', () => {
1366 expect(primaryChild.style.display).toBe('');
1367
1368 // Perform a hot update.
1361 - patch(() => {
1369 + await patch(() => {
1370 function Hello({children}) {
1371 const [val, setVal] = React.useState(0);
1372 return (
@@ -1404,7 +1412,7 @@ describe('ReactFresh', () => {
1412 expect(fallbackChild.style.display).toBe('');
1413
1414 // Perform a hot update.
1407 - patch(() => {
1415 + await patch(() => {
1416 function Hello({children}) {
1417 const [val, setVal] = React.useState(0);
1418 return (
@@ -1436,7 +1444,7 @@ describe('ReactFresh', () => {
1444 expect(primaryChild.style.display).toBe('');
1445
1446 // Perform a hot update.
1439 - patch(() => {
1447 + await patch(() => {
1448 function Hello({children}) {
1449 const [val, setVal] = React.useState(0);
1450 return (
@@ -1492,7 +1500,7 @@ describe('ReactFresh', () => {
1500 expect(appRenders).toBe(1);
1501
1502 // Perform a hot update for Hello only.
1495 - patch(() => {
1503 + await patch(() => {
1504 function Hello() {
1505 const [val, setVal] = React.useState(0);
1506 return (
@@ -1552,7 +1560,7 @@ describe('ReactFresh', () => {
1560 expect(container.textContent).toBe('XXXXXXXXXX');
1561 helloRenders = 0;
1562
1555 - patch(() => {
1563 + await patch(() => {
1564 function Hello({children}) {
1565 helloRenders++;
1566 return <div>O{children}O</div>;
@@ -1619,7 +1627,7 @@ describe('ReactFresh', () => {
1627 expect(el2.textContent).toBe('1');
1628
1629 // Perform a hot update for both inner components.
1622 - patch(() => {
1630 + await patch(() => {
1631 function Hello1() {
1632 const [val, setVal] = React.useState(0);
1633 return (
@@ -1681,7 +1689,7 @@ describe('ReactFresh', () => {
1689 expect(el.textContent).toBe('1');
1690
1691 // Perform a hot update.
1684 - const HelloV2 = patch(() => {
1692 + const HelloV2 = await patch(() => {
1693 function Hello() {
1694 const [val, setVal] = React.useState(0);
1695 return (
@@ -1702,7 +1710,7 @@ describe('ReactFresh', () => {
1710 expect(el.style.color).toBe('red');
1711
1712 // Perform a hot update.
1705 - const HelloV3 = patch(() => {
1713 + const HelloV3 = await patch(() => {
1714 function Hello() {
1715 const [val, setVal] = React.useState(0);
1716 return (
@@ -1743,7 +1751,7 @@ describe('ReactFresh', () => {
1751 expect(newEl.style.color).toBe('yellow');
1752
1753 // Verify we can patch again while preserving the signature.
1746 - patch(() => {
1754 + await patch(() => {
1755 function Hello() {
1756 const [val, setVal] = React.useState(0);
1757 return (
@@ -1763,7 +1771,7 @@ describe('ReactFresh', () => {
1771 expect(newEl.style.color).toBe('purple');
1772
1773 // Check removing the signature also causes a remount.
1766 - patch(() => {
1774 + await patch(() => {
1775 function Hello() {
1776 const [val, setVal] = React.useState(0);
1777 return (
@@ -1940,7 +1948,7 @@ describe('ReactFresh', () => {
1948 }, 10000);
1949
1950 async function runRemountingStressTest(tree) {
1943 - patch(() => {
1951 + await patch(() => {
1952 function Hello({children}) {
1953 return <section data-color="blue">{children}</section>;
1954 }
@@ -1961,7 +1969,7 @@ describe('ReactFresh', () => {
1969 });
1970
1971 // Patch color without changing the signature.
1964 - patch(() => {
1972 + await patch(() => {
1973 function Hello({children}) {
1974 return <section data-color="red">{children}</section>;
1975 }
@@ -1980,7 +1988,7 @@ describe('ReactFresh', () => {
1988 });
1989
1990 // Patch color *and* change the signature.
1983 - patch(() => {
1991 + await patch(() => {
1992 function Hello({children}) {
1993 return <section data-color="orange">{children}</section>;
1994 }
@@ -1999,7 +2007,7 @@ describe('ReactFresh', () => {
2007 });
2008
2009 // Now patch color but *don't* change the signature.
2002 - patch(() => {
2010 + await patch(() => {
2011 function Hello({children}) {
2012 return <section data-color="black">{children}</section>;
2013 }
@@ -2229,7 +2237,7 @@ describe('ReactFresh', () => {
2237 expect(el.textContent).toBe('1');
2238
2239 // Perform a hot update that doesn't remount.
2232 - patch(() => {
2240 + await patch(() => {
2241 function Hello() {
2242 const [val, setVal] = React.useState(0);
2243 return (
@@ -2250,7 +2258,7 @@ describe('ReactFresh', () => {
2258 expect(el.style.color).toBe('red');
2259
2260 // Perform a hot update that remounts.
2253 - patch(() => {
2261 + await patch(() => {
2262 function Hello() {
2263 const [val, setVal] = React.useState(0);
2264 return (
@@ -2279,7 +2287,7 @@ describe('ReactFresh', () => {
2287 expect(newEl.style.color).toBe('yellow');
2288
2289 // Verify we can patch again while preserving the signature.
2282 - patch(() => {
2290 + await patch(() => {
2291 function Hello() {
2292 const [val, setVal] = React.useState(0);
2293 return (
@@ -2299,7 +2307,7 @@ describe('ReactFresh', () => {
2307 expect(newEl.style.color).toBe('purple');
2308
2309 // Check removing the signature also causes a remount.
2302 - patch(() => {
2310 + await patch(() => {
2311 function Hello() {
2312 const [val, setVal] = React.useState(0);
2313 return (
@@ -2356,7 +2364,7 @@ describe('ReactFresh', () => {
2364 expect(useEffectWithEmptyArrayCalls).toBe(1); // useEffect didn't re-run
2365
2366 // Perform a hot update.
2359 - patch(() => {
2367 + await patch(() => {
2368 function Hello() {
2369 const [val, setVal] = React.useState(0);
2370 const tranformed = React.useMemo(() => val * 10, [val]);
@@ -2413,7 +2421,7 @@ describe('ReactFresh', () => {
2421 expect(el.style.color).toBe('blue');
2422
2423 // Perform a hot update.
2416 - patch(() => {
2424 + await patch(() => {
2425 function Hello() {
2426 const source = React.useMemo(() => ({value: 20}), []);
2427 const [state, setState] = React.useState({value: null});
@@ -2468,7 +2476,7 @@ describe('ReactFresh', () => {
2476 expect(el.firstChild).toBe(null); // Offscreen content not flushed yet.
2477
2478 // Perform a hot update.
2471 - patch(() => {
2479 + patchSync(() => {
2480 function Hello() {
2481 React.useLayoutEffect(() => {
2482 Scheduler.log('Hello#layout');
@@ -2507,7 +2515,7 @@ describe('ReactFresh', () => {
2515 expect(el.firstChild.style.color).toBe('red');
2516
2517 // Hot reload while we're offscreen.
2510 - patch(() => {
2518 + patchSync(() => {
2519 function Hello() {
2520 React.useLayoutEffect(() => {
2521 Scheduler.log('Hello#layout');
@@ -2575,7 +2583,7 @@ describe('ReactFresh', () => {
2583 const secondP = firstP.nextSibling.nextSibling;
2584
2585 // Perform a hot update that fails.
2578 - patch(() => {
2586 + await patch(() => {
2587 function Hello() {
2588 throw new Error('No');
2589 }
@@ -2587,7 +2595,7 @@ describe('ReactFresh', () => {
2595 expect(container.firstChild.nextSibling.nextSibling).toBe(secondP);
2596
2597 // Perform a hot update that fixes the error.
2590 - patch(() => {
2598 + await patch(() => {
2599 function Hello() {
2600 return <h1>Fixed!</h1>;
2601 }
@@ -2601,7 +2609,7 @@ describe('ReactFresh', () => {
2609
2610 // Verify next hot reload doesn't remount anything.
2611 const helloNode = container.firstChild.nextSibling;
2604 - patch(() => {
2612 + await patch(() => {
2613 function Hello() {
2614 return <h1>Nice.</h1>;
2615 }
@@ -2653,7 +2661,7 @@ describe('ReactFresh', () => {
2661 const secondP = firstP.nextSibling.nextSibling;
2662
2663 // Perform a hot update that fails.
2656 - patch(() => {
2664 + await patch(() => {
2665 function Hello() {
2666 throw new Error('No');
2667 }
@@ -2665,7 +2673,7 @@ describe('ReactFresh', () => {
2673 expect(container.firstChild.nextSibling.nextSibling).toBe(secondP);
2674
2675 // Perform a hot update that fixes the error.
2668 - patch(() => {
2676 + await patch(() => {
2677 function Hello() {
2678 return <h1>Fixed!</h1>;
2679 }
@@ -2679,7 +2687,7 @@ describe('ReactFresh', () => {
2687
2688 // Verify next hot reload doesn't remount anything.
2689 const helloNode = container.firstChild.nextSibling;
2682 - patch(() => {
2690 + await patch(() => {
2691 function Hello() {
2692 return <h1>Nice.</h1>;
2693 }
@@ -2735,7 +2743,7 @@ describe('ReactFresh', () => {
2743
2744 // Perform a hot update that fails.
2745 let crash;
2738 - patch(() => {
2746 + await patch(() => {
2747 function Hello() {
2748 const [x, setX] = React.useState('');
2749 React.useEffect(() => {
@@ -2761,7 +2769,7 @@ describe('ReactFresh', () => {
2769 expect(container.firstChild.nextSibling.nextSibling).toBe(secondP);
2770
2771 // Perform a hot update that fixes the error.
2764 - patch(() => {
2772 + await patch(() => {
2773 function Hello() {
2774 const [x] = React.useState('');
2775 React.useEffect(() => {}, []); // Removes the bad effect code.
@@ -2778,7 +2786,7 @@ describe('ReactFresh', () => {
2786
2787 // Verify next hot reload doesn't remount anything.
2788 const helloNode = container.firstChild.nextSibling;
2781 - patch(() => {
2789 + await patch(() => {
2790 function Hello() {
2791 const [x] = React.useState('');
2792 React.useEffect(() => {}, []);
@@ -2808,18 +2816,18 @@ describe('ReactFresh', () => {
2816 expect(container.innerHTML).toBe('');
2817
2818 // A bad retry
2811 - expect(() => {
2812 - patch(() => {
2819 + await expect(async () => {
2820 + await patch(() => {
2821 function Hello() {
2822 throw new Error('Not yet');
2823 }
2824 $RefreshReg$(Hello, 'Hello');
2825 });
2818 - }).toThrow('Not yet');
2826 + }).rejects.toThrow('Not yet');
2827 expect(container.innerHTML).toBe('');
2828
2829 // Perform a hot update that fixes the error.
2822 - patch(() => {
2830 + await patch(() => {
2831 function Hello() {
2832 return <h1>Fixed!</h1>;
2833 }
@@ -2829,25 +2837,25 @@ describe('ReactFresh', () => {
2837 expect(container.innerHTML).toBe('<h1>Fixed!</h1>');
2838
2839 // Ensure we can keep failing and recovering later.
2832 - expect(() => {
2833 - patch(() => {
2840 + await expect(async () => {
2841 + await patch(() => {
2842 function Hello() {
2843 throw new Error('No 2');
2844 }
2845 $RefreshReg$(Hello, 'Hello');
2846 });
2839 - }).toThrow('No 2');
2847 + }).rejects.toThrow('No 2');
2848 expect(container.innerHTML).toBe('');
2841 - expect(() => {
2842 - patch(() => {
2849 + await expect(async () => {
2850 + await patch(() => {
2851 function Hello() {
2852 throw new Error('Not yet 2');
2853 }
2854 $RefreshReg$(Hello, 'Hello');
2855 });
2848 - }).toThrow('Not yet 2');
2856 + }).rejects.toThrow('Not yet 2');
2857 expect(container.innerHTML).toBe('');
2850 - patch(() => {
2858 + await patch(() => {
2859 function Hello() {
2860 return <h1>Fixed 2!</h1>;
2861 }
@@ -2859,14 +2867,14 @@ describe('ReactFresh', () => {
2867 await act(() => {
2868 root.unmount();
2869 });
2862 - patch(() => {
2870 + await patch(() => {
2871 function Hello() {
2872 throw new Error('Ignored');
2873 }
2874 $RefreshReg$(Hello, 'Hello');
2875 });
2876 expect(container.innerHTML).toBe('');
2869 - patch(() => {
2877 + await patch(() => {
2878 function Hello() {
2879 return <h1>Ignored</h1>;
2880 }
@@ -2896,7 +2904,7 @@ describe('ReactFresh', () => {
2904 });
2905
2906 // Perform a hot update that fixes the error.
2899 - patch(() => {
2907 + await patch(() => {
2908 function Hello() {
2909 return <h1>Fixed!</h1>;
2910 }
@@ -2921,29 +2929,29 @@ describe('ReactFresh', () => {
2929
2930 // Perform a hot update that fails.
2931 // This removes the root.
2924 - expect(() => {
2925 - patch(() => {
2932 + await expect(async () => {
2933 + await patch(() => {
2934 function Hello() {
2935 throw new Error('No');
2936 }
2937 $RefreshReg$(Hello, 'Hello');
2938 });
2931 - }).toThrow('No');
2939 + }).rejects.toThrow('No');
2940 expect(container.innerHTML).toBe('');
2941
2942 // A bad retry
2935 - expect(() => {
2936 - patch(() => {
2943 + await expect(async () => {
2944 + await patch(() => {
2945 function Hello() {
2946 throw new Error('Not yet');
2947 }
2948 $RefreshReg$(Hello, 'Hello');
2949 });
2942 - }).toThrow('Not yet');
2950 + }).rejects.toThrow('Not yet');
2951 expect(container.innerHTML).toBe('');
2952
2953 // Perform a hot update that fixes the error.
2946 - patch(() => {
2954 + await patch(() => {
2955 function Hello() {
2956 return <h1>Fixed!</h1>;
2957 }
@@ -2954,7 +2962,7 @@ describe('ReactFresh', () => {
2962
2963 // Verify next hot reload doesn't remount anything.
2964 const helloNode = container.firstChild;
2957 - patch(() => {
2965 + await patch(() => {
2966 function Hello() {
2967 return <h1>Nice.</h1>;
2968 }
@@ -2964,18 +2972,18 @@ describe('ReactFresh', () => {
2972 expect(helloNode.textContent).toBe('Nice.');
2973
2974 // Break again.
2967 - expect(() => {
2968 - patch(() => {
2975 + await expect(async () => {
2976 + await patch(() => {
2977 function Hello() {
2978 throw new Error('Oops');
2979 }
2980 $RefreshReg$(Hello, 'Hello');
2981 });
2974 - }).toThrow('Oops');
2982 + }).rejects.toThrow('Oops');
2983 expect(container.innerHTML).toBe('');
2984
2985 // Perform a hot update that fixes the error.
2978 - patch(() => {
2986 + await patch(() => {
2987 function Hello() {
2988 return <h1>At last.</h1>;
2989 }
@@ -2989,7 +2997,7 @@ describe('ReactFresh', () => {
2997 root.unmount();
2998 });
2999 expect(container.innerHTML).toBe('');
2992 - patch(() => {
3000 + await patch(() => {
3001 function Hello() {
3002 return <h1>Never mind me!</h1>;
3003 }
@@ -3010,14 +3018,14 @@ describe('ReactFresh', () => {
3018 expect(container.innerHTML).toBe('<h1>Hi</h1>');
3019
3020 // Break again.
3013 - expect(() => {
3014 - patch(() => {
3021 + await expect(async () => {
3022 + await patch(() => {
3023 function Hello() {
3024 throw new Error('Oops');
3025 }
3026 $RefreshReg$(Hello, 'Hello');
3027 });
3020 - }).toThrow('Oops');
3028 + }).rejects.toThrow('Oops');
3029 expect(container.innerHTML).toBe('');
3030
3031 // Check we don't attempt to reverse an intentional unmount, even after an error.
@@ -3025,7 +3033,7 @@ describe('ReactFresh', () => {
3033 root.unmount();
3034 });
3035 expect(container.innerHTML).toBe('');
3028 - patch(() => {
3036 + await patch(() => {
3037 function Hello() {
3038 return <h1>Never mind me!</h1>;
3039 }
@@ -3139,7 +3147,7 @@ describe('ReactFresh', () => {
3147 expect(el.textContent).toBe('1');
3148
3149 // Perform a hot update.
3142 - const HelloV2 = patch(() => {
3150 + const HelloV2 = await patch(() => {
3151 class Hello extends React.Component {
3152 state = {count: 0};
3153 handleClick = () => {
@@ -3176,7 +3184,7 @@ describe('ReactFresh', () => {
3184 expect(newEl.style.color).toBe('red');
3185 expect(newEl.textContent).toBe('1');
3186
3179 - const HelloV3 = patch(() => {
3187 + const HelloV3 = await patch(() => {
3188 class Hello extends React.Component {
3189 state = {count: 0};
3190 handleClick = () => {
@@ -3235,7 +3243,7 @@ describe('ReactFresh', () => {
3243 );
3244 expect(testRef.current.getColor()).toBe('green');
3245
3238 - patch(() => {
3246 + await patch(() => {
3247 class Hello extends React.Component {
3248 getColor() {
3249 return 'orange';
@@ -3248,7 +3256,7 @@ describe('ReactFresh', () => {
3256 });
3257 expect(testRef.current.getColor()).toBe('orange');
3258
3251 - patch(() => {
3259 + await patch(() => {
3260 const Hello = React.forwardRef((props, ref) => {
3261 React.useImperativeHandle(ref, () => ({
3262 getColor() {
@@ -3261,7 +3269,7 @@ describe('ReactFresh', () => {
3269 });
3270 expect(testRef.current.getColor()).toBe('pink');
3271
3264 - patch(() => {
3272 + await patch(() => {
3273 const Hello = React.forwardRef((props, ref) => {
3274 React.useImperativeHandle(ref, () => ({
3275 getColor() {
@@ -3274,7 +3282,7 @@ describe('ReactFresh', () => {
3282 });
3283 expect(testRef.current.getColor()).toBe('yellow');
3284
3277 - patch(() => {
3285 + await patch(() => {
3286 const Hello = React.forwardRef((props, ref) => {
3287 React.useImperativeHandle(ref, () => ({
3288 getColor() {
@@ -3314,7 +3322,7 @@ describe('ReactFresh', () => {
3322 expect(el.textContent).toBe('1');
3323
3324 // Perform a hot update that turns it into a class.
3317 - const HelloV2 = patch(() => {
3325 + const HelloV2 = await patch(() => {
3326 class Hello extends React.Component {
3327 state = {count: 0};
3328 handleClick = () => {
@@ -3352,7 +3360,7 @@ describe('ReactFresh', () => {
3360 expect(newEl.textContent).toBe('1');
3361
3362 // Now convert it back to a function.
3355 - const HelloV3 = patch(() => {
3363 + const HelloV3 = await patch(() => {
3364 function Hello() {
3365 const [val, setVal] = React.useState(0);
3366 return (
@@ -3383,7 +3391,7 @@ describe('ReactFresh', () => {
3391 expect(finalEl.textContent).toBe('1');
3392
3393 // Now that it's a function, verify edits keep state.
3386 - patch(() => {
3394 + await patch(() => {
3395 function Hello() {
3396 const [val, setVal] = React.useState(0);
3397 return (
@@ -3872,7 +3880,7 @@ describe('ReactFresh', () => {
3880 expect(el.textContent).toBe('1');
3881
3882 // Perform a hot update.
3875 - patch(() => {
3883 + await patch(() => {
3884 function Hello() {
3885 const [val, setVal] = React.useState(0);
3886 return (
packages/react/src/ReactAct.js
+46 -6
@@ -19,6 +19,14 @@ let actScopeDepth = 0;
19 // We only warn the first time you neglect to await an async `act` scope.
20 let didWarnNoAwaitAct = false;
21
22 +function aggregateErrors(errors: Array<mixed>): mixed {
23 + if (errors.length > 1 && typeof AggregateError === 'function') {
24 + // eslint-disable-next-line no-undef
25 + return new AggregateError(errors);
26 + }
27 + return errors[0];
28 +}
29 +
30 export function act<T>(callback: () => T | Thenable<T>): Thenable<T> {
31 if (__DEV__) {
32 // 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> {
79 // one used to track `act` scopes. Why, you may be wondering? Because
80 // that's how it worked before version 18. Yes, it's confusing! We should
81 // delete legacy mode!!
82 + ReactCurrentActQueue.thrownErrors.push(error);
83 + }
84 + if (ReactCurrentActQueue.thrownErrors.length > 0) {
85 ReactCurrentActQueue.isBatchingLegacy = prevIsBatchingLegacy;
86 popActScope(prevActQueue, prevActScopeDepth);
76 - throw error;
87 + const thrownError = aggregateErrors(ReactCurrentActQueue.thrownErrors);
88 + ReactCurrentActQueue.thrownErrors.length = 0;
89 + throw thrownError;
90 }
91
92 if (
@@ -123,7 +136,14 @@ export function act<T>(callback: () => T | Thenable<T>): Thenable<T> {
136 // `thenable` might not be a real promise, and `flushActQueue`
137 // might throw, so we need to wrap `flushActQueue` in a
138 // try/catch.
126 - reject(error);
139 + ReactCurrentActQueue.thrownErrors.push(error);
140 + }
141 + if (ReactCurrentActQueue.thrownErrors.length > 0) {
142 + const thrownError = aggregateErrors(
143 + ReactCurrentActQueue.thrownErrors,
144 + );
145 + ReactCurrentActQueue.thrownErrors.length = 0;
146 + reject(thrownError);
147 }
148 } else {
149 resolve(returnValue);
@@ -131,7 +151,15 @@ export function act<T>(callback: () => T | Thenable<T>): Thenable<T> {
151 },
152 error => {
153 popActScope(prevActQueue, prevActScopeDepth);
134 - reject(error);
154 + if (ReactCurrentActQueue.thrownErrors.length > 0) {
155 + const thrownError = aggregateErrors(
156 + ReactCurrentActQueue.thrownErrors,
157 + );
158 + ReactCurrentActQueue.thrownErrors.length = 0;
159 + reject(thrownError);
160 + } else {
161 + reject(error);
162 + }
163 },
164 );
165 },
@@ -183,6 +211,13 @@ export function act<T>(callback: () => T | Thenable<T>): Thenable<T> {
211 // to be awaited, regardless of whether the callback is sync or async.
212 ReactCurrentActQueue.current = null;
213 }
214 +
215 + if (ReactCurrentActQueue.thrownErrors.length > 0) {
216 + const thrownError = aggregateErrors(ReactCurrentActQueue.thrownErrors);
217 + ReactCurrentActQueue.thrownErrors.length = 0;
218 + throw thrownError;
219 + }
220 +
221 return {
222 then(resolve: T => mixed, reject: mixed => mixed) {
223 didAwaitActCall = true;
@@ -239,15 +274,20 @@ function recursivelyFlushAsyncActWork<T>(
274 queueMacrotask(() =>
275 recursivelyFlushAsyncActWork(returnValue, resolve, reject),
276 );
277 + return;
278 } catch (error) {
279 // Leave remaining tasks on the queue if something throws.
244 - reject(error);
280 + ReactCurrentActQueue.thrownErrors.push(error);
281 }
282 } else {
283 // The queue is empty. We can finish.
284 ReactCurrentActQueue.current = null;
249 - resolve(returnValue);
285 }
286 + }
287 + if (ReactCurrentActQueue.thrownErrors.length > 0) {
288 + const thrownError = aggregateErrors(ReactCurrentActQueue.thrownErrors);
289 + ReactCurrentActQueue.thrownErrors.length = 0;
290 + reject(thrownError);
291 } else {
292 resolve(returnValue);
293 }
@@ -287,7 +327,7 @@ function flushActQueue(queue: Array<RendererTask>) {
327 } catch (error) {
328 // If something throws, leave the remaining callbacks on the queue.
329 queue.splice(0, i + 1);
290 - throw error;
330 + ReactCurrentActQueue.thrownErrors.push(error);
331 } finally {
332 isFlushing = false;
333 }
packages/react/src/ReactCurrentActQueue.js
+3
@@ -20,6 +20,9 @@ const ReactCurrentActQueue = {
20 // Determines whether we should yield to microtasks to unwrap already resolved
21 // promises without suspending.
22 didUsePromise: false,
23 +
24 + // Track first uncaught error within this act
25 + thrownErrors: ([]: Array<mixed>),
26 };
27
28 export default ReactCurrentActQueue;
packages/react/src/ReactStartTransition.js
+4 -15
@@ -15,6 +15,8 @@ import {
15 enableTransitionTracing,
16 } from 'shared/ReactFeatureFlags';
17
18 +import reportGlobalError from 'shared/reportGlobalError';
19 +
20 export function startTransition(
21 scope: () => void,
22 options?: StartTransitionOptions,
@@ -51,10 +53,10 @@ export function startTransition(
53 typeof returnValue.then === 'function'
54 ) {
55 callbacks.forEach(callback => callback(currentTransition, returnValue));
54 - returnValue.then(noop, onError);
56 + returnValue.then(noop, reportGlobalError);
57 }
58 } catch (error) {
57 - onError(error);
59 + reportGlobalError(error);
60 } finally {
61 warnAboutTransitionSubscriptions(prevTransition, currentTransition);
62 ReactCurrentBatchConfig.transition = prevTransition;
@@ -91,16 +93,3 @@ function warnAboutTransitionSubscriptions(
93 }
94
95 function noop() {}
94 -
95 -// Use reportError, if it exists. Otherwise console.error. This is the same as
96 -// the default for onRecoverableError.
97 -const onError =
98 - typeof reportError === 'function'
99 - ? // In modern browsers, reportError will dispatch an error event,
100 - // emulating an uncaught JavaScript error.
101 - reportError
102 - : (error: mixed) => {
103 - // In older browsers and test environments, fallback to console.error.
104 - // eslint-disable-next-line react-internal/no-production-logging
105 - console['error'](error);
106 - };
packages/react/src/__tests__/ReactCoffeeScriptClass-test.coffee
+13 -5
@@ -9,7 +9,6 @@ PropTypes = null
9 React = null
10 ReactDOM = null
11 ReactDOMClient = null
12 -act = null
12
13 featureFlags = require 'shared/ReactFeatureFlags'
14
@@ -49,16 +48,25 @@ describe 'ReactCoffeeScriptClass', ->
48
49 it 'throws if no render function is defined', ->
50 class Foo extends React.Component
51 + caughtErrors = []
52 + errorHandler = (event) ->
53 + event.preventDefault()
54 + caughtErrors.push(event.error)
55 + window.addEventListener 'error', errorHandler;
56 expect(->
53 - expect(->
54 - ReactDOM.flushSync ->
55 - root.render React.createElement(Foo)
56 - ).toThrow()
57 + ReactDOM.flushSync ->
58 + root.render React.createElement(Foo)
59 ).toErrorDev([
60 # A failed component renders twice in DEV in concurrent mode
61 'No `render` method found on the Foo instance',
62 'No `render` method found on the Foo instance',
63 ])
64 + window.removeEventListener 'error', errorHandler;
65 + expect(caughtErrors).toEqual([
66 + expect.objectContaining(
67 + message: expect.stringContaining('is not a function')
68 + )
69 + ])
70
71 it 'renders a simple stateless component with prop', ->
72 class Foo extends React.Component
packages/react/src/__tests__/ReactES6Class-test.js
+23 -8
@@ -60,14 +60,29 @@ describe('ReactES6Class', () => {
60
61 it('throws if no render function is defined', () => {
62 class Foo extends React.Component {}
63 - expect(() => {
64 - expect(() => ReactDOM.flushSync(() => root.render(<Foo />))).toThrow();
65 - }).toErrorDev([
66 - // A failed component renders twice in DEV in concurrent mode
67 - 'Warning: No `render` method found on the Foo instance: ' +
68 - 'you may have forgotten to define `render`.',
69 - 'Warning: No `render` method found on the Foo instance: ' +
70 - 'you may have forgotten to define `render`.',
63 + const caughtErrors = [];
64 + function errorHandler(event) {
65 + event.preventDefault();
66 + caughtErrors.push(event.error);
67 + }
68 + window.addEventListener('error', errorHandler);
69 + try {
70 + expect(() => {
71 + ReactDOM.flushSync(() => root.render(<Foo />));
72 + }).toErrorDev([
73 + // A failed component renders twice in DEV in concurrent mode
74 + 'Warning: No `render` method found on the Foo instance: ' +
75 + 'you may have forgotten to define `render`.',
76 + 'Warning: No `render` method found on the Foo instance: ' +
77 + 'you may have forgotten to define `render`.',
78 + ]);
79 + } finally {
80 + window.removeEventListener('error', errorHandler);
81 + }
82 + expect(caughtErrors).toEqual([
83 + expect.objectContaining({
84 + message: expect.stringContaining('is not a function'),
85 + }),
86 ]);
87 });
88
packages/react/src/__tests__/ReactTypeScriptClass-test.ts
+20 -10
@@ -327,17 +327,27 @@ describe('ReactTypeScriptClass', function() {
327 });
328
329 it('throws if no render function is defined', function() {
330 - expect(() => {
331 - expect(() =>
330 + class Foo extends React.Component {}
331 + const caughtErrors = [];
332 + function errorHandler(event) {
333 + event.preventDefault();
334 + caughtErrors.push(event.error);
335 + }
336 + window.addEventListener('error', errorHandler);
337 + try {
338 + expect(() => {
339 ReactDOM.flushSync(() => root.render(React.createElement(Empty)))
333 - ).toThrow();
334 - }).toErrorDev([
335 - // A failed component renders twice in DEV in concurrent mode
336 - 'Warning: No `render` method found on the Empty instance: ' +
337 - 'you may have forgotten to define `render`.',
338 - 'Warning: No `render` method found on the Empty instance: ' +
339 - 'you may have forgotten to define `render`.',
340 - ]);
340 + }).toErrorDev([
341 + // A failed component renders twice in DEV in concurrent mode
342 + 'Warning: No `render` method found on the Empty instance: ' +
343 + 'you may have forgotten to define `render`.',
344 + 'Warning: No `render` method found on the Empty instance: ' +
345 + 'you may have forgotten to define `render`.',
346 + ]);
347 + } finally {
348 + window.removeEventListener('error', errorHandler);
349 + }
350 + expect(caughtErrors.length).toBe(1);
351 });
352
353 it('renders a simple stateless component with prop', function() {
packages/shared/reportGlobalError.js new
+52
@@ -0,0 +1,52 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +const reportGlobalError: (error: mixed) => void =
11 + typeof reportError === 'function'
12 + ? // In modern browsers, reportError will dispatch an error event,
13 + // emulating an uncaught JavaScript error.
14 + reportError
15 + : error => {
16 + if (
17 + typeof window === 'object' &&
18 + typeof window.ErrorEvent === 'function'
19 + ) {
20 + // Browser Polyfill
21 + const message =
22 + typeof error === 'object' &&
23 + error !== null &&
24 + typeof error.message === 'string'
25 + ? // eslint-disable-next-line react-internal/safe-string-coercion
26 + String(error.message)
27 + : // eslint-disable-next-line react-internal/safe-string-coercion
28 + String(error);
29 + const event = new window.ErrorEvent('error', {
30 + bubbles: true,
31 + cancelable: true,
32 + message: message,
33 + error: error,
34 + });
35 + const shouldLog = window.dispatchEvent(event);
36 + if (!shouldLog) {
37 + return;
38 + }
39 + } else if (
40 + typeof process === 'object' &&
41 + // $FlowFixMe[method-unbinding]
42 + typeof process.emit === 'function'
43 + ) {
44 + // Node Polyfill
45 + process.emit('uncaughtException', error);
46 + return;
47 + }
48 + // eslint-disable-next-line react-internal/no-production-logging
49 + console['error'](error);
50 + };
51 +
52 +export default reportGlobalError;
packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js
+102 -50
@@ -143,7 +143,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
143 };
144 }
145
146 - test('basic usage', async () => {
146 + it('basic usage', async () => {
147 const store = createExternalStore('Initial');
148
149 function App() {
@@ -165,7 +165,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
165 expect(container.textContent).toEqual('Updated');
166 });
167
168 - test('skips re-rendering if nothing changes', async () => {
168 + it('skips re-rendering if nothing changes', async () => {
169 const store = createExternalStore('Initial');
170
171 function App() {
@@ -189,7 +189,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
189 expect(container.textContent).toEqual('Initial');
190 });
191
192 - test('switch to a different store', async () => {
192 + it('switch to a different store', async () => {
193 const storeA = createExternalStore(0);
194 const storeB = createExternalStore(0);
195
@@ -242,7 +242,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
242 expect(container.textContent).toEqual('1');
243 });
244
245 - test('selecting a specific value inside getSnapshot', async () => {
245 + it('selecting a specific value inside getSnapshot', async () => {
246 const store = createExternalStore({a: 0, b: 0});
247
248 function A() {
@@ -290,7 +290,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
290 // In React 18, you can't observe in between a sync render and its
291 // passive effects, so this is only relevant to legacy roots
292 // @gate enableUseSyncExternalStoreShim
293 - test(
293 + it(
294 "compares to current state before bailing out, even when there's a " +
295 'mutation in between the sync and passive effects',
296 async () => {
@@ -334,7 +334,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
334 },
335 );
336
337 - test('mutating the store in between render and commit when getSnapshot has changed', async () => {
337 + it('mutating the store in between render and commit when getSnapshot has changed', async () => {
338 const store = createExternalStore({a: 1, b: 1});
339
340 const getSnapshotA = () => store.getState().a;
@@ -394,7 +394,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
394 expect(container.textContent).toEqual('B2');
395 });
396
397 - test('mutating the store in between render and commit when getSnapshot has _not_ changed', async () => {
397 + it('mutating the store in between render and commit when getSnapshot has _not_ changed', async () => {
398 // Same as previous test, but `getSnapshot` does not change
399 const store = createExternalStore({a: 1, b: 1});
400
@@ -453,7 +453,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
453 expect(container.textContent).toEqual('A1');
454 });
455
456 - test("does not bail out if the previous update hasn't finished yet", async () => {
456 + it("does not bail out if the previous update hasn't finished yet", async () => {
457 const store = createExternalStore(0);
458
459 function Child1() {
@@ -492,7 +492,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
492 expect(container.textContent).toEqual('00');
493 });
494
495 - test('uses the latest getSnapshot, even if it changed in the same batch as a store update', async () => {
495 + it('uses the latest getSnapshot, even if it changed in the same batch as a store update', async () => {
496 const store = createExternalStore({a: 0, b: 0});
497
498 const getSnapshotA = () => store.getState().a;
@@ -523,7 +523,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
523 expect(container.textContent).toEqual('2');
524 });
525
526 - test('handles errors thrown by getSnapshot', async () => {
526 + it('handles errors thrown by getSnapshot', async () => {
527 class ErrorBoundary extends React.Component {
528 state = {error: null};
529 static getDerivedStateFromError(error) {
@@ -568,23 +568,41 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
568 expect(container.textContent).toEqual('0');
569
570 // Update that throws in a getSnapshot. We can catch it with an error boundary.
571 - await act(() => {
572 - store.set({value: 1, throwInGetSnapshot: true, throwInIsEqual: false});
573 - });
574 - if (gate(flags => !flags.enableUseSyncExternalStoreShim)) {
575 - assertLog([
576 - 'Error in getSnapshot',
577 - // In a concurrent root, React renders a second time to attempt to
578 - // recover from the error.
579 - 'Error in getSnapshot',
580 - ]);
571 + if (__DEV__ && gate(flags => flags.enableUseSyncExternalStoreShim)) {
572 + // In 17, the error is re-thrown in DEV.
573 + await expect(async () => {
574 + await act(() => {
575 + store.set({
576 + value: 1,
577 + throwInGetSnapshot: true,
578 + throwInIsEqual: false,
579 + });
580 + });
581 + }).rejects.toThrow('Error in getSnapshot');
582 } else {
582 - assertLog(['Error in getSnapshot']);
583 + await act(() => {
584 + store.set({
585 + value: 1,
586 + throwInGetSnapshot: true,
587 + throwInIsEqual: false,
588 + });
589 + });
590 }
591 +
592 + assertLog(
593 + gate(flags => flags.enableUseSyncExternalStoreShim)
594 + ? ['Error in getSnapshot']
595 + : [
596 + 'Error in getSnapshot',
597 + // In a concurrent root, React renders a second time to attempt to
598 + // recover from the error.
599 + 'Error in getSnapshot',
600 + ],
601 + );
602 expect(container.textContent).toEqual('Error in getSnapshot');
603 });
604
587 - test('Infinite loop if getSnapshot keeps returning new reference', async () => {
605 + it('Infinite loop if getSnapshot keeps returning new reference', async () => {
606 const store = createExternalStore({});
607
608 function App() {
@@ -596,9 +614,11 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
614 const root = createRoot(container);
615
616 await expect(async () => {
599 - expect(() =>
600 - ReactDOM.flushSync(async () => root.render(<App />)),
601 - ).toThrow(
617 + await expect(async () => {
618 + await act(() => {
619 + ReactDOM.flushSync(async () => root.render(<App />));
620 + });
621 + }).rejects.toThrow(
622 'Maximum update depth exceeded. This can happen when a component repeatedly ' +
623 'calls setState inside componentWillUpdate or componentDidUpdate. React limits ' +
624 'the number of nested updates to prevent infinite loops.',
@@ -606,7 +626,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
626 }).toErrorDev(
627 gate(flags => flags.enableUseSyncExternalStoreShim)
628 ? [
609 - 'Uncaught [',
629 + 'Maximum update depth exceeded. ',
630 'The result of getSnapshot should be cached to avoid an infinite loop',
631 'The above error occurred in the',
632 ]
@@ -625,7 +645,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
645 );
646 });
647
628 - test('getSnapshot can return NaN without infinite loop warning', async () => {
648 + it('getSnapshot can return NaN without infinite loop warning', async () => {
649 const store = createExternalStore('not a number');
650
651 function App() {
@@ -655,7 +675,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
675 describe('extra features implemented in user-space', () => {
676 // The selector implementation uses the lazy ref initialization pattern
677 // @gate !(enableUseRefAccessWarning && __DEV__)
658 - test('memoized selectors are only called once per update', async () => {
678 + it('memoized selectors are only called once per update', async () => {
679 const store = createExternalStore({a: 0, b: 0});
680
681 function selector(state) {
@@ -698,7 +718,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
718
719 // The selector implementation uses the lazy ref initialization pattern
720 // @gate !(enableUseRefAccessWarning && __DEV__)
701 - test('Using isEqual to bailout', async () => {
721 + it('Using isEqual to bailout', async () => {
722 const store = createExternalStore({a: 0, b: 0});
723
724 function A() {
@@ -757,7 +777,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
777 expect(container.textContent).toEqual('A1B1');
778 });
779
760 - test('basic server hydration', async () => {
780 + it('basic server hydration', async () => {
781 const store = createExternalStore('client');
782
783 const ref = React.createRef();
@@ -810,7 +830,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
830 });
831 });
832
813 - test('regression test for #23150', async () => {
833 + it('regression test for #23150', async () => {
834 const store = createExternalStore('Initial');
835
836 function App() {
@@ -839,7 +859,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
859
860 // The selector implementation uses the lazy ref initialization pattern
861 // @gate !(enableUseRefAccessWarning && __DEV__)
842 - test('compares selection to rendered selection even if selector changes', async () => {
862 + it('compares selection to rendered selection even if selector changes', async () => {
863 const store = createExternalStore({items: ['A', 'B']});
864
865 const shallowEqualArray = (a, b) => {
@@ -958,15 +978,31 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
978
979 expect(container.textContent).toEqual('A');
980
961 - await expect(async () => {
962 - await act(() => {
963 - store.set({});
964 - });
965 - }).toWarnDev(
966 - ReactFeatureFlags.enableUseRefAccessWarning
967 - ? ['Warning: App: Unsafe read of a mutable value during render.']
968 - : [],
969 - );
981 + if (__DEV__ && gate(flags => flags.enableUseSyncExternalStoreShim)) {
982 + // In 17, the error is re-thrown in DEV.
983 + await expect(async () => {
984 + await expect(async () => {
985 + await act(() => {
986 + store.set({});
987 + });
988 + }).rejects.toThrow('Malformed state');
989 + }).toWarnDev(
990 + ReactFeatureFlags.enableUseRefAccessWarning
991 + ? ['Warning: App: Unsafe read of a mutable value during render.']
992 + : [],
993 + );
994 + } else {
995 + await expect(async () => {
996 + await act(() => {
997 + store.set({});
998 + });
999 + }).toWarnDev(
1000 + ReactFeatureFlags.enableUseRefAccessWarning
1001 + ? ['Warning: App: Unsafe read of a mutable value during render.']
1002 + : [],
1003 + );
1004 + }
1005 +
1006 expect(container.textContent).toEqual('Malformed state');
1007 });
1008
@@ -1003,15 +1039,31 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
1039
1040 expect(container.textContent).toEqual('A');
1041
1006 - await expect(async () => {
1007 - await act(() => {
1008 - store.set({});
1009 - });
1010 - }).toWarnDev(
1011 - ReactFeatureFlags.enableUseRefAccessWarning
1012 - ? ['Warning: App: Unsafe read of a mutable value during render.']
1013 - : [],
1014 - );
1042 + if (__DEV__ && gate(flags => flags.enableUseSyncExternalStoreShim)) {
1043 + // In 17, the error is re-thrown in DEV.
1044 + await expect(async () => {
1045 + await expect(async () => {
1046 + await act(() => {
1047 + store.set({});
1048 + });
1049 + }).rejects.toThrow('Malformed state');
1050 + }).toWarnDev(
1051 + ReactFeatureFlags.enableUseRefAccessWarning
1052 + ? ['Warning: App: Unsafe read of a mutable value during render.']
1053 + : [],
1054 + );
1055 + } else {
1056 + await expect(async () => {
1057 + await act(() => {
1058 + store.set({});
1059 + });
1060 + }).toWarnDev(
1061 + ReactFeatureFlags.enableUseRefAccessWarning
1062 + ? ['Warning: App: Unsafe read of a mutable value during render.']
1063 + : [],
1064 + );
1065 + }
1066 +
1067 expect(container.textContent).toEqual('Malformed state');
1068 });
1069 });
scripts/jest/matchers/toWarnDev.js
+1 -5
@@ -71,11 +71,7 @@ const createMatcherFor = (consoleMethod, matcherName) =>
71 const consoleSpy = (format, ...args) => {
72 // Ignore uncaught errors reported by jsdom
73 // and React addendums because they're too noisy.
74 - if (
75 - !logAllErrors &&
76 - consoleMethod === 'error' &&
77 - shouldIgnoreConsoleError(format, args)
78 - ) {
74 + if (!logAllErrors && shouldIgnoreConsoleError(format, args)) {
75 return;
76 }
77
scripts/jest/setupTests.js
+1 -1
@@ -68,7 +68,7 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) {
68 const newMethod = function (format, ...args) {
69 // Ignore uncaught errors reported by jsdom
70 // and React addendums because they're too noisy.
71 - if (methodName === 'error' && shouldIgnoreConsoleError(format, args)) {
71 + if (shouldIgnoreConsoleError(format, args)) {
72 return;
73 }
74
scripts/jest/shouldIgnoreConsoleError.js
+5 -2
@@ -9,8 +9,11 @@ module.exports = function shouldIgnoreConsoleError(
9 if (typeof format === 'string') {
10 if (
11 args[0] != null &&
12 - typeof args[0].message === 'string' &&
13 - typeof args[0].stack === 'string'
12 + ((typeof args[0] === 'object' &&
13 + typeof args[0].message === 'string' &&
14 + typeof args[0].stack === 'string') ||
15 + (typeof args[0] === 'string' &&
16 + args[0].indexOf('An error occurred in ') === 0))
17 ) {
18 // This looks like an error with addendum from ReactFiberErrorLogger.
19 // They are noisy too so we'll try to ignore them.
scripts/rollup/validate/eslintrc.fb.js
+1
@@ -40,6 +40,7 @@ module.exports = {
40 // FB
41 __DEV__: 'readonly',
42 // Node.js Server Rendering
43 + process: 'readonly',
44 setImmediate: 'readonly',
45 Buffer: 'readonly',
46 // Trusted Types
scripts/rollup/validate/eslintrc.rn.js
+3
@@ -53,6 +53,9 @@ module.exports = {
53 reportError: 'readonly',
54 AggregateError: 'readonly',
55
56 + // Node Feature Detection
57 + process: 'readonly',
58 +
59 // Temp
60 AsyncLocalStorage: 'readonly',
61 async_hooks: 'readonly',
scripts/rollup/validate/eslintrc.umd.js
+3
@@ -57,6 +57,9 @@ module.exports = {
57 // Flight
58 Promise: 'readonly',
59
60 + // Node Feature Detection
61 + process: 'readonly',
62 +
63 // Temp
64 AsyncLocalStorage: 'readonly',
65 async_hooks: 'readonly',