Skip to content

Commit 2dadb9e

Browse files
motiz88facebook-github-bot
authored andcommitted
Move React error message formatting into ExceptionsManager
Summary: # Context In facebook/react#16141 we imported `ReactFiberErrorDialog` unchanged from React. That implementation was not idempotent: if passed the same error instance multiple times, it would amend its `message` property every time, eventually leading to bloat and low-signal logs. The message bloat problem is most evident when rendering multiple `lazy()` components that expose the same Error reference to React (e.g. due to some cache that vends the same rejected Promise multiple times). More broadly, there's a need for structured, machine-readable logging to replace stringly-typed interfaces in both the production and development use cases. # This diff * We leave the user-supplied `message` field intact and instead do all the formatting inside `ExceptionsManager`. To avoid needless complexity, this **doesn't** always have the exact same output as the old code (but it does come close). See tests for the specifics. * The only mutation we do on React-captured error instances is setting the `componentStack` expando property. This replaces any previously-captured component stack rather than adding to it, and so doesn't create bloat. * We also report the exception fields `componentStack`, unformatted `message` (as `originalMessage`) and `name` directly to `NativeExceptionsManager` for future use. Reviewed By: cpojer Differential Revision: D16331228 fbshipit-source-id: 7b0539c2c83c7dd4e56db8508afcf367931ac71d
1 parent d6d7181 commit 2dadb9e

File tree

7 files changed

+171
-42
lines changed

7 files changed

+171
-42
lines changed

Libraries/Core/Devtools/parseErrorStack.js

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export type ExtendedError = Error & {
1616
framesToPop?: number,
1717
jsEngine?: string,
1818
preventSymbolication?: boolean,
19+
componentStack?: string,
1920
};
2021

2122
function parseErrorStack(e: ExtendedError): Array<StackFrame> {

Libraries/Core/ExceptionsManager.js

+38-11
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212

1313
import type {ExtendedError} from './Devtools/parseErrorStack';
1414

15+
class SyntheticError extends Error {
16+
name = '';
17+
}
18+
1519
/**
1620
* Handles the developer-visible aspect of errors and exceptions
1721
*/
@@ -22,10 +26,35 @@ function reportException(e: ExtendedError, isFatal: boolean) {
2226
const parseErrorStack = require('./Devtools/parseErrorStack');
2327
const stack = parseErrorStack(e);
2428
const currentExceptionID = ++exceptionID;
25-
const message =
26-
e.jsEngine == null ? e.message : `${e.message}, js engine: ${e.jsEngine}`;
29+
const originalMessage = e.message || '';
30+
let message = originalMessage;
31+
if (e.componentStack != null) {
32+
message += `\n\nThis error is located at:${e.componentStack}`;
33+
}
34+
const namePrefix = e.name == null || e.name === '' ? '' : `${e.name}: `;
35+
const isFromConsoleError = e.name === 'console.error';
36+
37+
if (!message.startsWith(namePrefix)) {
38+
message = namePrefix + message;
39+
}
40+
41+
// Errors created by `console.error` have already been printed.
42+
if (!isFromConsoleError) {
43+
if (console._errorOriginal) {
44+
console._errorOriginal(message);
45+
} else {
46+
console.error(message);
47+
}
48+
}
49+
50+
message =
51+
e.jsEngine == null ? message : `${message}, js engine: ${e.jsEngine}`;
2752
NativeExceptionsManager.reportException({
2853
message,
54+
originalMessage: message === originalMessage ? null : originalMessage,
55+
name: e.name == null || e.name === '' ? null : e.name,
56+
componentStack:
57+
typeof e.componentStack === 'string' ? e.componentStack : null,
2958
stack,
3059
id: currentExceptionID,
3160
isFatal,
@@ -77,25 +106,22 @@ function handleException(e: Error, isFatal: boolean) {
77106
// `throw '<error message>'` somewhere in your codebase.
78107
if (!e.message) {
79108
// $FlowFixMe - cannot reassign constant, explanation above
80-
e = new Error(e);
81-
}
82-
if (console._errorOriginal) {
83-
console._errorOriginal(e.message);
84-
} else {
85-
console.error(e.message);
109+
e = new SyntheticError(e);
86110
}
87111
reportException(e, isFatal);
88112
}
89113

90114
function reactConsoleErrorHandler() {
91-
console._errorOriginal.apply(console, arguments);
92115
if (!console.reportErrorsAsExceptions) {
116+
console._errorOriginal.apply(console, arguments);
93117
return;
94118
}
95119

96120
if (arguments[0] && arguments[0].stack) {
121+
// reportException will console.error this with high enough fidelity.
97122
reportException(arguments[0], /* isFatal */ false);
98123
} else {
124+
console._errorOriginal.apply(console, arguments);
99125
const stringifySafe = require('../Utilities/stringifySafe');
100126
const str = Array.prototype.map.call(arguments, stringifySafe).join(', ');
101127
if (str.slice(0, 10) === '"Warning: ') {
@@ -104,7 +130,8 @@ function reactConsoleErrorHandler() {
104130
// (Note: Logic duplicated in polyfills/console.js.)
105131
return;
106132
}
107-
const error: ExtendedError = new Error('console.error: ' + str);
133+
const error: ExtendedError = new SyntheticError(str);
134+
error.name = 'console.error';
108135
error.framesToPop = 1;
109136
reportException(error, /* isFatal */ false);
110137
}
@@ -129,4 +156,4 @@ function installConsoleErrorReporter() {
129156
}
130157
}
131158

132-
module.exports = {handleException, installConsoleErrorReporter};
159+
module.exports = {handleException, installConsoleErrorReporter, SyntheticError};

Libraries/Core/NativeExceptionsManager.js

+3
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export type StackFrame = {|
2323

2424
export type ExceptionData = {
2525
message: string,
26+
originalMessage: ?string,
27+
name: ?string,
28+
componentStack: ?string,
2629
stack: Array<StackFrame>,
2730
id: number,
2831
isFatal: boolean,

Libraries/Core/ReactFiberErrorDialog.js

+14-18
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* LICENSE file in the root directory of this source tree.
66
*
77
* @format
8-
* @flow strict-local
8+
* @flow
99
*/
1010

1111
export type CapturedError = {
@@ -18,36 +18,30 @@ export type CapturedError = {
1818
+willRetry: boolean,
1919
};
2020

21-
import {handleException} from './ExceptionsManager';
21+
import type {ExtendedError} from './Devtools/parseErrorStack';
22+
23+
import {handleException, SyntheticError} from './ExceptionsManager';
2224

2325
/**
2426
* Intercept lifecycle errors and ensure they are shown with the correct stack
2527
* trace within the native redbox component.
2628
*/
27-
export function showErrorDialog(capturedError: CapturedError): boolean {
29+
function showErrorDialog(capturedError: CapturedError): boolean {
2830
const {componentStack, error} = capturedError;
2931

30-
let errorToHandle: Error;
32+
let errorToHandle;
3133

3234
// Typically Errors are thrown but eg strings or null can be thrown as well.
3335
if (error instanceof Error) {
34-
const {message, name} = error;
35-
36-
const summary = message ? `${name}: ${message}` : name;
37-
38-
errorToHandle = error;
39-
40-
try {
41-
errorToHandle.message = `${summary}\n\nThis error is located at:${componentStack}`;
42-
} catch (e) {}
36+
errorToHandle = (error: ExtendedError);
4337
} else if (typeof error === 'string') {
44-
errorToHandle = new Error(
45-
`${error}\n\nThis error is located at:${componentStack}`,
46-
);
38+
errorToHandle = (new SyntheticError(error): ExtendedError);
4739
} else {
48-
errorToHandle = new Error(`Unspecified error at:${componentStack}`);
40+
errorToHandle = (new SyntheticError('Unspecified error'): ExtendedError);
4941
}
50-
42+
try {
43+
errorToHandle.componentStack = componentStack;
44+
} catch (e) {}
5145
handleException(errorToHandle, false);
5246

5347
// Return false here to prevent ReactFiberErrorLogger default behavior of
@@ -56,3 +50,5 @@ export function showErrorDialog(capturedError: CapturedError): boolean {
5650
// done above by calling ExceptionsManager.
5751
return false;
5852
}
53+
54+
module.exports = {showErrorDialog};

Libraries/Core/__tests__/ExceptionsManager-test.js

+95-11
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ describe('ExceptionsManager', () => {
5757
test('forwards error instance to reportException', () => {
5858
const error = new ReferenceError('Some error happened');
5959
// Copy all the data we care about before any possible mutation.
60-
const {message} = error;
60+
const {message, name} = error;
6161

6262
const logToConsoleInReact = ReactFiberErrorDialog.showErrorDialog({
6363
...capturedErrorDefaults,
@@ -73,6 +73,11 @@ describe('ExceptionsManager', () => {
7373
'This error is located at:' +
7474
capturedErrorDefaults.componentStack;
7575
expect(exceptionData.message).toBe(formattedMessage);
76+
expect(exceptionData.originalMessage).toBe(message);
77+
expect(exceptionData.name).toBe(name);
78+
expect(exceptionData.componentStack).toBe(
79+
capturedErrorDefaults.componentStack,
80+
);
7681
expect(getLineFromFrame(exceptionData.stack[0])).toBe(
7782
"const error = new ReferenceError('Some error happened');",
7883
);
@@ -149,6 +154,10 @@ describe('ExceptionsManager', () => {
149154
'This error is located at:' +
150155
capturedErrorDefaults.componentStack;
151156
expect(exceptionData.message).toBe(formattedMessage);
157+
expect(exceptionData.originalMessage).toBe(message);
158+
expect(exceptionData.componentStack).toBe(
159+
capturedErrorDefaults.componentStack,
160+
);
152161
expect(exceptionData.stack[0].file).toMatch(/ReactFiberErrorDialog\.js$/);
153162
expect(exceptionData.isFatal).toBe(false);
154163
expect(logToConsoleInReact).toBe(false);
@@ -164,8 +173,16 @@ describe('ExceptionsManager', () => {
164173
expect(nativeReportException.mock.calls.length).toBe(1);
165174
const exceptionData = nativeReportException.mock.calls[0][0];
166175
const formattedMessage =
167-
'Unspecified error at:' + capturedErrorDefaults.componentStack;
176+
'Unspecified error' +
177+
'\n\n' +
178+
'This error is located at:' +
179+
capturedErrorDefaults.componentStack;
168180
expect(exceptionData.message).toBe(formattedMessage);
181+
expect(exceptionData.originalMessage).toBe('Unspecified error');
182+
expect(exceptionData.name).toBe(null);
183+
expect(exceptionData.componentStack).toBe(
184+
capturedErrorDefaults.componentStack,
185+
);
169186
expect(exceptionData.stack[0].file).toMatch(/ReactFiberErrorDialog\.js$/);
170187
expect(exceptionData.isFatal).toBe(false);
171188
expect(logToConsoleInReact).toBe(false);
@@ -186,6 +203,55 @@ describe('ExceptionsManager', () => {
186203
"const error = Object.freeze(new Error('Some error happened'));",
187204
);
188205
});
206+
207+
test('does not mutate the message', () => {
208+
const error = new ReferenceError('Some error happened');
209+
const {message} = error;
210+
211+
ReactFiberErrorDialog.showErrorDialog({
212+
...capturedErrorDefaults,
213+
error,
214+
});
215+
216+
expect(nativeReportException).toHaveBeenCalled();
217+
expect(error.message).toBe(message);
218+
});
219+
220+
test('can safely process the same error multiple times', () => {
221+
const error = new ReferenceError('Some error happened');
222+
// Copy all the data we care about before any possible mutation.
223+
const {message} = error;
224+
const componentStacks = [
225+
'\n in A\n in B\n in C',
226+
'\n in X\n in Y\n in Z',
227+
];
228+
for (const componentStack of componentStacks) {
229+
nativeReportException.mockClear();
230+
const formattedMessage =
231+
'ReferenceError: ' +
232+
message +
233+
'\n\n' +
234+
'This error is located at:' +
235+
componentStack;
236+
const logToConsoleInReact = ReactFiberErrorDialog.showErrorDialog({
237+
...capturedErrorDefaults,
238+
componentStack,
239+
error,
240+
});
241+
242+
expect(nativeReportException.mock.calls.length).toBe(1);
243+
const exceptionData = nativeReportException.mock.calls[0][0];
244+
expect(exceptionData.message).toBe(formattedMessage);
245+
expect(exceptionData.originalMessage).toBe(message);
246+
expect(exceptionData.componentStack).toBe(componentStack);
247+
expect(getLineFromFrame(exceptionData.stack[0])).toBe(
248+
"const error = new ReferenceError('Some error happened');",
249+
);
250+
expect(exceptionData.isFatal).toBe(false);
251+
expect(logToConsoleInReact).toBe(false);
252+
expect(console.error).toBeCalledWith(formattedMessage);
253+
}
254+
});
189255
});
190256

191257
describe('console.error handler', () => {
@@ -208,19 +274,22 @@ describe('ExceptionsManager', () => {
208274

209275
test('logging an Error', () => {
210276
const error = new Error('Some error happened');
211-
const {message} = error;
277+
const {message, name} = error;
212278

213279
console.error(error);
214280

215281
expect(nativeReportException.mock.calls.length).toBe(1);
216282
const exceptionData = nativeReportException.mock.calls[0][0];
217-
expect(exceptionData.message).toBe(message);
283+
const formattedMessage = 'Error: ' + message;
284+
expect(exceptionData.message).toBe(formattedMessage);
285+
expect(exceptionData.originalMessage).toBe(message);
286+
expect(exceptionData.name).toBe(name);
218287
expect(getLineFromFrame(exceptionData.stack[0])).toBe(
219288
"const error = new Error('Some error happened');",
220289
);
221290
expect(exceptionData.isFatal).toBe(false);
222291
expect(mockError.mock.calls[0]).toHaveLength(1);
223-
expect(mockError.mock.calls[0][0]).toBe(error);
292+
expect(mockError.mock.calls[0][0]).toBe(formattedMessage);
224293
});
225294

226295
test('logging a string', () => {
@@ -230,8 +299,11 @@ describe('ExceptionsManager', () => {
230299

231300
expect(nativeReportException.mock.calls.length).toBe(1);
232301
const exceptionData = nativeReportException.mock.calls[0][0];
233-
const formattedMessage = 'console.error: "Some error happened"';
234-
expect(exceptionData.message).toBe(formattedMessage);
302+
expect(exceptionData.message).toBe(
303+
'console.error: "Some error happened"',
304+
);
305+
expect(exceptionData.originalMessage).toBe('"Some error happened"');
306+
expect(exceptionData.name).toBe('console.error');
235307
expect(getLineFromFrame(exceptionData.stack[0])).toBe(
236308
'console.error(message);',
237309
);
@@ -249,6 +321,10 @@ describe('ExceptionsManager', () => {
249321
expect(exceptionData.message).toBe(
250322
'console.error: 42, true, ["symbol" failed to stringify], {"y":null}',
251323
);
324+
expect(exceptionData.originalMessage).toBe(
325+
'42, true, ["symbol" failed to stringify], {"y":null}',
326+
);
327+
expect(exceptionData.name).toBe('console.error');
252328
expect(getLineFromFrame(exceptionData.stack[0])).toBe(
253329
'console.error(...args);',
254330
);
@@ -317,13 +393,16 @@ describe('ExceptionsManager', () => {
317393

318394
expect(nativeReportException.mock.calls.length).toBe(1);
319395
const exceptionData = nativeReportException.mock.calls[0][0];
320-
expect(exceptionData.message).toBe(message);
396+
const formattedMessage = 'Error: ' + message;
397+
expect(exceptionData.message).toBe(formattedMessage);
398+
expect(exceptionData.originalMessage).toBe(message);
399+
expect(exceptionData.name).toBe('Error');
321400
expect(getLineFromFrame(exceptionData.stack[0])).toBe(
322401
"const error = new Error('Some error happened');",
323402
);
324403
expect(exceptionData.isFatal).toBe(true);
325404
expect(console.error.mock.calls[0]).toHaveLength(1);
326-
expect(console.error.mock.calls[0][0]).toBe(message);
405+
expect(console.error.mock.calls[0][0]).toBe(formattedMessage);
327406
});
328407

329408
test('handling a non-fatal Error', () => {
@@ -334,13 +413,16 @@ describe('ExceptionsManager', () => {
334413

335414
expect(nativeReportException.mock.calls.length).toBe(1);
336415
const exceptionData = nativeReportException.mock.calls[0][0];
337-
expect(exceptionData.message).toBe(message);
416+
const formattedMessage = 'Error: ' + message;
417+
expect(exceptionData.message).toBe(formattedMessage);
418+
expect(exceptionData.originalMessage).toBe(message);
419+
expect(exceptionData.name).toBe('Error');
338420
expect(getLineFromFrame(exceptionData.stack[0])).toBe(
339421
"const error = new Error('Some error happened');",
340422
);
341423
expect(exceptionData.isFatal).toBe(false);
342424
expect(console.error.mock.calls[0]).toHaveLength(1);
343-
expect(console.error.mock.calls[0][0]).toBe(message);
425+
expect(console.error.mock.calls[0][0]).toBe(formattedMessage);
344426
});
345427

346428
test('handling a thrown string', () => {
@@ -351,6 +433,8 @@ describe('ExceptionsManager', () => {
351433
expect(nativeReportException.mock.calls.length).toBe(1);
352434
const exceptionData = nativeReportException.mock.calls[0][0];
353435
expect(exceptionData.message).toBe(message);
436+
expect(exceptionData.originalMessage).toBe(null);
437+
expect(exceptionData.name).toBe(null);
354438
expect(exceptionData.stack[0].file).toMatch(/ExceptionsManager\.js$/);
355439
expect(exceptionData.isFatal).toBe(true);
356440
expect(console.error.mock.calls[0]).toEqual([message]);

0 commit comments

Comments
 (0)