Skip to content

Commit ffb82cb

Browse files
mweststratefacebook-github-bot
authored andcommitted
Make sure exceptions and console.errors are send to metro
Summary: changelog: [General] `console.error` calls, and uncaught exceptions are now displayed in the Metro logs as well Reviewed By: passy Differential Revision: D19743075 fbshipit-source-id: a665a06cfc7854ae785af177af8f2478bb1d76b0
1 parent cb80e3b commit ffb82cb

File tree

4 files changed

+163
-22
lines changed

4 files changed

+163
-22
lines changed

Libraries/Core/ExceptionsManager.js

+66-16
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,11 @@ function preprocessException(data: ExceptionData): ExceptionData {
5252
* Handles the developer-visible aspect of errors and exceptions
5353
*/
5454
let exceptionID = 0;
55-
function reportException(e: ExtendedError, isFatal: boolean) {
55+
function reportException(
56+
e: ExtendedError,
57+
isFatal: boolean,
58+
reportToConsole: boolean, // only true when coming from handleException; the error has not yet been logged
59+
) {
5660
const NativeExceptionsManager = require('./NativeExceptionsManager').default;
5761
if (NativeExceptionsManager) {
5862
const parseErrorStack = require('./Devtools/parseErrorStack');
@@ -64,21 +68,11 @@ function reportException(e: ExtendedError, isFatal: boolean) {
6468
message += `\n\nThis error is located at:${e.componentStack}`;
6569
}
6670
const namePrefix = e.name == null || e.name === '' ? '' : `${e.name}: `;
67-
const isFromConsoleError = e.name === 'console.error';
6871

6972
if (!message.startsWith(namePrefix)) {
7073
message = namePrefix + message;
7174
}
7275

73-
// Errors created by `console.error` have already been printed.
74-
if (!isFromConsoleError) {
75-
if (console._errorOriginal) {
76-
console._errorOriginal(message);
77-
} else {
78-
console.error(message);
79-
}
80-
}
81-
8276
message =
8377
e.jsEngine == null ? message : `${message}, js engine: ${e.jsEngine}`;
8478

@@ -104,6 +98,13 @@ function reportException(e: ExtendedError, isFatal: boolean) {
10498
},
10599
});
106100

101+
if (reportToConsole) {
102+
// we feed back into console.error, to make sure any methods that are
103+
// monkey patched on top of console.error are called when coming from
104+
// handleException
105+
console.error(data.message);
106+
}
107+
107108
if (isHandledByLogBox) {
108109
LogBoxData.addException({
109110
...data,
@@ -134,6 +135,11 @@ function reportException(e: ExtendedError, isFatal: boolean) {
134135
console.log('Unable to symbolicate stack trace: ' + error.message);
135136
});
136137
}
138+
} else if (reportToConsole) {
139+
// we feed back into console.error, to make sure any methods that are
140+
// monkey patched on top of console.error are called when coming from
141+
// handleException
142+
console.error(e);
137143
}
138144
}
139145

@@ -143,6 +149,10 @@ declare var console: typeof console & {
143149
...
144150
};
145151

152+
// If we trigger console.error _from_ handleException,
153+
// we do want to make sure that console.error doesn't trigger error reporting again
154+
let inExceptionHandler = false;
155+
146156
/**
147157
* Logs exceptions to the (native) console and displays them
148158
*/
@@ -157,20 +167,60 @@ function handleException(e: mixed, isFatal: boolean) {
157167
// `throw '<error message>'` somewhere in your codebase.
158168
error = new SyntheticError(e);
159169
}
160-
reportException(error, isFatal);
170+
try {
171+
inExceptionHandler = true;
172+
reportException(error, isFatal, /*reportToConsole*/ true);
173+
} finally {
174+
inExceptionHandler = false;
175+
}
161176
}
162177

163178
function reactConsoleErrorHandler() {
179+
// bubble up to any original handlers
180+
console._errorOriginal.apply(console, arguments);
164181
if (!console.reportErrorsAsExceptions) {
165-
console._errorOriginal.apply(console, arguments);
182+
return;
183+
}
184+
if (inExceptionHandler) {
185+
// The fundamental trick here is that are multiple entry point to logging errors:
186+
// (see D19743075 for more background)
187+
//
188+
// 1. An uncaught exception being caught by the global handler
189+
// 2. An error being logged throw console.error
190+
//
191+
// However, console.error is monkey patched multiple times: by this module, and by the
192+
// DevTools setup that sends messages to Metro.
193+
// The patching order cannot be relied upon.
194+
//
195+
// So, some scenarios that are handled by this flag:
196+
//
197+
// Logging an error:
198+
// 1. console.error called from user code
199+
// 2. (possibly) arrives _first_ at DevTool handler, send to Metro
200+
// 3. Bubbles to here
201+
// 4. goes into report Exception.
202+
// 5. should not trigger console.error again, to avoid looping / logging twice
203+
// 6. should still bubble up to original console
204+
// (which might either be console.log, or the DevTools handler in case it patched _earlier_ and (2) didn't happen)
205+
//
206+
// Throwing an uncaught exception:
207+
// 1. exception thrown
208+
// 2. picked up by handleException
209+
// 3. should be send to console.error (not console._errorOriginal, as DevTools might have patched _later_ and it needs to send it to Metro)
210+
// 4. that _might_ bubble again to the `reactConsoleErrorHandle` defined here
211+
// -> should not handle exception _again_, to avoid looping / showing twice (this code branch)
212+
// 5. should still bubble up to original console (which might either be console.log, or the DevTools handler in case that one patched _earlier_)
166213
return;
167214
}
168215

169216
if (arguments[0] && arguments[0].stack) {
170217
// reportException will console.error this with high enough fidelity.
171-
reportException(arguments[0], /* isFatal */ false);
218+
reportException(
219+
arguments[0],
220+
/* isFatal */ false,
221+
/*reportToConsole*/ false,
222+
);
172223
} else {
173-
console._errorOriginal.apply(console, arguments);
174224
const stringifySafe = require('../Utilities/stringifySafe');
175225
const str = Array.prototype.map
176226
.call(arguments, value =>
@@ -186,7 +236,7 @@ function reactConsoleErrorHandler() {
186236
}
187237
const error: ExtendedError = new SyntheticError(str);
188238
error.name = 'console.error';
189-
reportException(error, /* isFatal */ false);
239+
reportException(error, /* isFatal */ false, /*reportToConsole*/ false);
190240
}
191241
}
192242

Libraries/Core/__tests__/ExceptionsManager-test.js

+95-6
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,9 @@ describe('ExceptionsManager', () => {
137137
message +
138138
'\n\n' +
139139
'This error is located at:' +
140-
capturedErrorDefaults.componentStack,
141-
// JS engine omitted here!
140+
capturedErrorDefaults.componentStack +
141+
', js engine: ' +
142+
jsEngine,
142143
);
143144
});
144145

@@ -293,7 +294,8 @@ describe('ExceptionsManager', () => {
293294
);
294295
expect(exceptionData.isFatal).toBe(false);
295296
expect(mockError.mock.calls[0]).toHaveLength(1);
296-
expect(mockError.mock.calls[0][0]).toBe(formattedMessage);
297+
expect(mockError.mock.calls[0][0]).toBeInstanceOf(Error);
298+
expect(mockError.mock.calls[0][0].toString()).toBe(formattedMessage);
297299
});
298300

299301
test('logging a string', () => {
@@ -461,6 +463,23 @@ describe('ExceptionsManager', () => {
461463
});
462464

463465
describe('unstable_setExceptionDecorator', () => {
466+
let mockError;
467+
beforeEach(() => {
468+
// NOTE: We initialise a fresh mock every time using spyOn, above.
469+
// We can't use `console._errorOriginal` for this, because that's a bound
470+
// (=wrapped) version of the mock and Jest does not approve.
471+
mockError = console.error;
472+
ExceptionsManager.installConsoleErrorReporter();
473+
});
474+
475+
afterEach(() => {
476+
// There is no uninstallConsoleErrorReporter. Do this so the next install
477+
// works.
478+
console.error = console._errorOriginal;
479+
delete console._errorOriginal;
480+
delete console.reportErrorsAsExceptions;
481+
});
482+
464483
test('modifying the exception data', () => {
465484
const error = new Error('Some error happened');
466485
const decorator = jest.fn().mockImplementation(data => ({
@@ -509,7 +528,7 @@ describe('ExceptionsManager', () => {
509528
expect(nativeReportException).toHaveBeenCalled();
510529
});
511530

512-
test('prevents decorator recursion', () => {
531+
test('prevents decorator recursion from error handler', () => {
513532
const error = new Error('Some error happened');
514533
const decorator = jest.fn().mockImplementation(data => {
515534
console.error('Logging an error within the decorator');
@@ -519,18 +538,88 @@ describe('ExceptionsManager', () => {
519538
};
520539
});
521540

522-
ExceptionsManager.installConsoleErrorReporter();
523541
ExceptionsManager.unstable_setExceptionDecorator(decorator);
524542
ExceptionsManager.handleException(error, true);
525543

526-
expect(decorator).toHaveBeenCalled();
544+
expect(nativeReportException).toHaveBeenCalledTimes(1);
545+
expect(nativeReportException.mock.calls[0][0].message).toMatch(
546+
/decorated: .*Some error happened/,
547+
);
548+
expect(mockError).toHaveBeenCalledTimes(2);
549+
expect(mockError.mock.calls[0][0]).toMatch(
550+
/Logging an error within the decorator/,
551+
);
552+
expect(mockError.mock.calls[1][0]).toMatch(
553+
/decorated: .*Some error happened/,
554+
);
555+
});
556+
557+
test('prevents decorator recursion from console.error', () => {
558+
const error = new Error('Some error happened');
559+
const decorator = jest.fn().mockImplementation(data => {
560+
console.error('Logging an error within the decorator');
561+
return {
562+
...data,
563+
message: 'decorated: ' + data.message,
564+
};
565+
});
566+
567+
ExceptionsManager.unstable_setExceptionDecorator(decorator);
568+
console.error(error);
569+
527570
expect(nativeReportException).toHaveBeenCalledTimes(2);
528571
expect(nativeReportException.mock.calls[0][0].message).toMatch(
529572
/Logging an error within the decorator/,
530573
);
531574
expect(nativeReportException.mock.calls[1][0].message).toMatch(
532575
/decorated: .*Some error happened/,
533576
);
577+
expect(mockError).toHaveBeenCalledTimes(2);
578+
// console.error calls are chained without exception pre-processing, so decorator doesn't apply
579+
expect(mockError.mock.calls[0][0].toString()).toMatch(
580+
/Error: Some error happened/,
581+
);
582+
expect(mockError.mock.calls[1][0]).toMatch(
583+
/Logging an error within the decorator/,
584+
);
585+
});
586+
587+
test('can handle throwing decorators recursion when exception is thrown', () => {
588+
const error = new Error('Some error happened');
589+
const decorator = jest.fn().mockImplementation(data => {
590+
throw new Error('Throwing an error within the decorator');
591+
});
592+
593+
ExceptionsManager.unstable_setExceptionDecorator(decorator);
594+
ExceptionsManager.handleException(error, true);
595+
596+
expect(nativeReportException).toHaveBeenCalledTimes(1);
597+
// Exceptions in decorators are ignored and the decorator is not applied
598+
expect(nativeReportException.mock.calls[0][0].message).toMatch(
599+
/Error: Some error happened/,
600+
);
601+
expect(mockError).toHaveBeenCalledTimes(1);
602+
expect(mockError.mock.calls[0][0]).toMatch(/Error: Some error happened/);
603+
});
604+
605+
test('can handle throwing decorators recursion when exception is logged', () => {
606+
const error = new Error('Some error happened');
607+
const decorator = jest.fn().mockImplementation(data => {
608+
throw new Error('Throwing an error within the decorator');
609+
});
610+
611+
ExceptionsManager.unstable_setExceptionDecorator(decorator);
612+
console.error(error);
613+
614+
expect(nativeReportException).toHaveBeenCalledTimes(1);
615+
// Exceptions in decorators are ignored and the decorator is not applied
616+
expect(nativeReportException.mock.calls[0][0].message).toMatch(
617+
/Error: Some error happened/,
618+
);
619+
expect(mockError).toHaveBeenCalledTimes(1);
620+
expect(mockError.mock.calls[0][0].toString()).toMatch(
621+
/Error: Some error happened/,
622+
);
534623
});
535624
});
536625
});

Libraries/Core/setUpDeveloperTools.js

+1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ if (__DEV__) {
5454
'trace',
5555
'info',
5656
'warn',
57+
'error',
5758
'log',
5859
'group',
5960
'groupCollapsed',

Libraries/Utilities/HMRClient.js

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type LogLevel =
3131
| 'trace'
3232
| 'info'
3333
| 'warn'
34+
| 'error'
3435
| 'log'
3536
| 'group'
3637
| 'groupCollapsed'

0 commit comments

Comments
 (0)