main
js 349 lines 10.3 KB
Raw
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 * @emails react-core
8 * @jest-environment node
9 */
10
11 'use strict';
12
13 let React;
14 let ReactNoop;
15 let Scheduler;
16 let waitForAll;
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(() => {
27 jest.resetModules();
28 React = require('react');
29 ReactNoop = require('react-noop-renderer');
30 Scheduler = require('scheduler');
31
32 const InternalTestUtils = require('internal-test-utils');
33 waitForAll = InternalTestUtils.waitForAll;
34 });
35
36 // Note: in this test file we won't be using assertConsoleDev() 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 () => {
59 class ErrorThrowingComponent extends React.Component {
60 constructor(props) {
61 super(props);
62 throw new Error('constructor error');
63 }
64 render() {
65 return <div />;
66 }
67 }
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 );
83 if (__DEV__) {
84 expect(console.warn).toHaveBeenCalledTimes(1);
85 expect(console.warn).toHaveBeenCalledWith(
86 expect.stringContaining('%s'),
87 expect.stringContaining(
88 'An error occurred in the <ErrorThrowingComponent> component.',
89 ),
90 expect.stringContaining(
91 'Consider adding an error boundary to your tree ' +
92 'to customize error handling behavior.',
93 ),
94 // The component stack is not added without the polyfill/devtools.
95 // expect.stringMatching(
96 // new RegExp(
97 // '\\s+(in|at) ErrorThrowingComponent'
98 // ),
99 // ),
100 );
101 }
102 });
103
104 it('should log errors that occur during the commit phase', async () => {
105 class ErrorThrowingComponent extends React.Component {
106 componentDidMount() {
107 throw new Error('componentDidMount error');
108 }
109 render() {
110 return <div />;
111 }
112 }
113 await fakeAct(() => {
114 ReactNoop.render(
115 <div>
116 <span>
117 <ErrorThrowingComponent />
118 </span>
119 </div>,
120 );
121 });
122 expect(uncaughtExceptionMock).toHaveBeenCalledTimes(1);
123 expect(uncaughtExceptionMock).toHaveBeenCalledWith(
124 expect.objectContaining({
125 message: 'componentDidMount error',
126 }),
127 );
128 if (__DEV__) {
129 expect(console.warn).toHaveBeenCalledTimes(1);
130 expect(console.warn).toHaveBeenCalledWith(
131 expect.stringContaining('%s'),
132 expect.stringContaining(
133 'An error occurred in the <ErrorThrowingComponent> component.',
134 ),
135 expect.stringContaining(
136 'Consider adding an error boundary to your tree ' +
137 'to customize error handling behavior.',
138 ),
139 // The component stack is not added without the polyfill/devtools.
140 // expect.stringMatching(
141 // new RegExp(
142 // '\\s+(in|at) ErrorThrowingComponent'
143 // ),
144 // ),
145 );
146 }
147 });
148
149 it('should ignore errors thrown in log method to prevent cycle', async () => {
150 const logCapturedErrorCalls = [];
151 console.error.mockImplementation(error => {
152 // Test what happens when logging itself is buggy.
153 logCapturedErrorCalls.push(error);
154 throw new Error('logCapturedError error');
155 });
156
157 class ErrorBoundary extends React.Component {
158 state = {error: null};
159 componentDidCatch(error) {
160 this.setState({error});
161 }
162 render() {
163 return this.state.error ? null : this.props.children;
164 }
165 }
166 class ErrorThrowingComponent extends React.Component {
167 render() {
168 throw new Error('render error');
169 }
170 }
171 await fakeAct(() => {
172 ReactNoop.render(
173 <div>
174 <ErrorBoundary>
175 <span>
176 <ErrorThrowingComponent />
177 </span>
178 </ErrorBoundary>
179 </div>,
180 );
181 });
182 expect(logCapturedErrorCalls.length).toBe(1);
183 if (__DEV__) {
184 expect(console.error).toHaveBeenCalledWith(
185 expect.stringContaining('%o'),
186 expect.objectContaining({
187 message: 'render error',
188 }),
189 expect.stringContaining(
190 'The above error occurred in the <ErrorThrowingComponent> component.',
191 ),
192 expect.stringContaining(
193 'React will try to recreate this component tree from scratch ' +
194 'using the error boundary you provided, ErrorBoundary.',
195 ),
196 // The component stack is not added without the polyfill/devtools.
197 // expect.stringMatching(
198 // new RegExp(
199 // '\\s+(in|at) ErrorThrowingComponent'
200 // ),
201 // ),
202 );
203 } else {
204 expect(logCapturedErrorCalls[0]).toEqual(
205 expect.objectContaining({
206 message: 'render error',
207 }),
208 );
209 }
210 // The error thrown in logCapturedError should be rethrown with a clean stack
211 expect(() => {
212 jest.runAllTimers();
213 }).toThrow('logCapturedError error');
214 });
215
216 it('does not report internal Offscreen component for errors thrown during reconciliation inside Suspense', async () => {
217 // When a child of Suspense throws during reconciliation (not render),
218 // a Throw fiber is created whose .return is the internal Offscreen fiber.
219 // We should skip Offscreen since it's an internal
220 // implementation detail and walk up to Suspense instead.
221 const lazyChild = React.lazy(() => {
222 throw new Error('lazy init error');
223 });
224
225 await fakeAct(() => {
226 ReactNoop.render(
227 <React.Suspense fallback={<div />}>{lazyChild}</React.Suspense>,
228 );
229 });
230 expect(uncaughtExceptionMock).toHaveBeenCalledTimes(1);
231 expect(uncaughtExceptionMock).toHaveBeenCalledWith(
232 expect.objectContaining({
233 message: 'lazy init error',
234 }),
235 );
236 if (__DEV__) {
237 expect(console.warn).toHaveBeenCalledTimes(1);
238 expect(console.warn.mock.calls[0]).toEqual([
239 '%s\n\n%s\n',
240 'An error occurred in the <Suspense> component.',
241 'Consider adding an error boundary to your tree to customize error handling behavior.\n' +
242 'Visit https://react.dev/link/error-boundaries to learn more about error boundaries.',
243 ]);
244 }
245 });
246
247 it('does not report internal Offscreen component for errors thrown during reconciliation inside Activity', async () => {
248 // Same as the Suspense test above — Activity also wraps its children in
249 // an internal Offscreen fiber. The error message should show Activity,
250 // not Offscreen.
251 const lazyChild = React.lazy(() => {
252 throw new Error('lazy init error');
253 });
254
255 await fakeAct(() => {
256 ReactNoop.render(
257 <React.Activity mode="visible">{lazyChild}</React.Activity>,
258 );
259 });
260 expect(uncaughtExceptionMock).toHaveBeenCalledTimes(1);
261 expect(uncaughtExceptionMock).toHaveBeenCalledWith(
262 expect.objectContaining({
263 message: 'lazy init error',
264 }),
265 );
266 if (__DEV__) {
267 expect(console.warn).toHaveBeenCalledTimes(1);
268 expect(console.warn.mock.calls[0]).toEqual([
269 '%s\n\n%s\n',
270 'An error occurred in the <Activity> component.',
271 'Consider adding an error boundary to your tree to customize error handling behavior.\n' +
272 'Visit https://react.dev/link/error-boundaries to learn more about error boundaries.',
273 ]);
274 }
275 });
276
277 it('resets instance variables before unmounting failed node', async () => {
278 class ErrorBoundary extends React.Component {
279 state = {error: null};
280 componentDidCatch(error) {
281 this.setState({error});
282 }
283 render() {
284 return this.state.error ? null : this.props.children;
285 }
286 }
287 class Foo extends React.Component {
288 state = {step: 0};
289 componentDidMount() {
290 this.setState({step: 1});
291 }
292 componentWillUnmount() {
293 Scheduler.log('componentWillUnmount: ' + this.state.step);
294 }
295 render() {
296 Scheduler.log('render: ' + this.state.step);
297 if (this.state.step > 0) {
298 throw new Error('oops');
299 }
300 return null;
301 }
302 }
303
304 ReactNoop.render(
305 <ErrorBoundary>
306 <Foo />
307 </ErrorBoundary>,
308 );
309 await waitForAll(
310 [
311 'render: 0',
312
313 'render: 1',
314
315 // Retry one more time before handling error
316 'render: 1',
317
318 'componentWillUnmount: 0',
319 ].filter(Boolean),
320 );
321
322 expect(console.error).toHaveBeenCalledTimes(1);
323 if (__DEV__) {
324 expect(console.error).toHaveBeenCalledWith(
325 expect.stringContaining('%o'),
326 expect.objectContaining({
327 message: 'oops',
328 }),
329 expect.stringContaining(
330 'The above error occurred in the <Foo> component.',
331 ),
332 expect.stringContaining(
333 'React will try to recreate this component tree from scratch ' +
334 'using the error boundary you provided, ErrorBoundary.',
335 ),
336 // The component stack is not added without the polyfill/devtools.
337 // expect.stringMatching(
338 // new RegExp('\\s+(in|at) Foo')
339 // ),
340 );
341 } else {
342 expect(console.error).toHaveBeenCalledWith(
343 expect.objectContaining({
344 message: 'oops',
345 }),
346 );
347 }
348 });
349 });