From a9cab21010146eea7f6e5e22c026bd297468bbca Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Tue, 16 Jul 2019 03:17:35 -0700 Subject: [PATCH] Add ReactFiberErrorDialog from React + tests (#25671) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/25671 Moves the RN-specific `ReactFiberErrorDialog` implementation from React to RN for easier iteration. Also adds new unit tests. This current change is additive, so we're compatible with the current React renderer which still uses `ExceptionsManager` and not the file added here. After the corresponding React update we can remove `ExceptionsManager` from the RN private interface entirely. Reviewed By: cpojer Differential Revision: D16278938 fbshipit-source-id: 0c2c0c3e65e524e079730ae3b0cc23e0c0bdc5fd --- Libraries/Core/ReactFiberErrorDialog.js | 58 ++++++++++ .../__tests__/ReactFiberErrorDialog-test.js | 109 ++++++++++++++++++ .../ReactNativePrivateInterface.js | 3 + 3 files changed, 170 insertions(+) create mode 100644 Libraries/Core/ReactFiberErrorDialog.js create mode 100644 Libraries/Core/__tests__/ReactFiberErrorDialog-test.js diff --git a/Libraries/Core/ReactFiberErrorDialog.js b/Libraries/Core/ReactFiberErrorDialog.js new file mode 100644 index 0000000000..5d798d4647 --- /dev/null +++ b/Libraries/Core/ReactFiberErrorDialog.js @@ -0,0 +1,58 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow strict-local + */ + +export type CapturedError = { + +componentName: ?string, + +componentStack: string, + +error: mixed, + +errorBoundary: ?{}, + +errorBoundaryFound: boolean, + +errorBoundaryName: string | null, + +willRetry: boolean, +}; + +import {handleException} from './ExceptionsManager'; + +/** + * Intercept lifecycle errors and ensure they are shown with the correct stack + * trace within the native redbox component. + */ +export function showErrorDialog(capturedError: CapturedError): boolean { + const {componentStack, error} = capturedError; + + let errorToHandle: Error; + + // Typically Errors are thrown but eg strings or null can be thrown as well. + if (error instanceof Error) { + const {message, name} = error; + + const summary = message ? `${name}: ${message}` : name; + + errorToHandle = error; + + try { + errorToHandle.message = `${summary}\n\nThis error is located at:${componentStack}`; + } catch (e) {} + } else if (typeof error === 'string') { + errorToHandle = new Error( + `${error}\n\nThis error is located at:${componentStack}`, + ); + } else { + errorToHandle = new Error(`Unspecified error at:${componentStack}`); + } + + handleException(errorToHandle, false); + + // Return false here to prevent ReactFiberErrorLogger default behavior of + // logging error details to console.error. Calls to console.error are + // automatically routed to the native redbox controller, which we've already + // done above by calling ExceptionsManager. + return false; +} diff --git a/Libraries/Core/__tests__/ReactFiberErrorDialog-test.js b/Libraries/Core/__tests__/ReactFiberErrorDialog-test.js new file mode 100644 index 0000000000..c9393a0309 --- /dev/null +++ b/Libraries/Core/__tests__/ReactFiberErrorDialog-test.js @@ -0,0 +1,109 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @emails oncall+react_native + */ +'use strict'; + +const capturedErrorDefaults = { + componentName: 'A', + componentStack: '\n in A\n in B\n in C', + errorBoundary: null, + errorBoundaryFound: false, + errorBoundaryName: null, + willRetry: false, +}; + +describe('ReactFiberErrorDialog', () => { + let ReactFiberErrorDialog, ExceptionsManager; + beforeEach(() => { + jest.resetModules(); + jest.mock('../ExceptionsManager', () => { + return { + handleException: jest.fn(), + }; + }); + ReactFiberErrorDialog = require('../ReactFiberErrorDialog'); + ExceptionsManager = require('../ExceptionsManager'); + }); + + describe('showErrorDialog', () => { + test('forwards error instance to handleException', () => { + const error = new ReferenceError('Some error happened'); + error.someCustomProp = 42; + // Copy all the data we care about before any possible mutation. + const {name, stack, message, someCustomProp} = error; + + const logToConsole = ReactFiberErrorDialog.showErrorDialog({ + ...capturedErrorDefaults, + error, + }); + + expect(ExceptionsManager.handleException.mock.calls.length).toBe(1); + const errorArg = ExceptionsManager.handleException.mock.calls[0][0]; + const isFatalArg = ExceptionsManager.handleException.mock.calls[0][1]; + // We intentionally don't test whether errorArg === error, because this + // implementation detail might change. Instead, we test that they are + // functionally equivalent. + expect(errorArg).toBeInstanceOf(ReferenceError); + expect(errorArg).toHaveProperty('name', name); + expect(errorArg).toHaveProperty('stack', stack); + expect(errorArg).toHaveProperty('someCustomProp', someCustomProp); + expect(errorArg).toHaveProperty( + 'message', + 'ReferenceError: ' + + message + + '\n\n' + + 'This error is located at:' + + capturedErrorDefaults.componentStack, + ); + expect(isFatalArg).toBe(false); + expect(logToConsole).toBe(false); + }); + + test('wraps string in an Error and sends to handleException', () => { + const message = 'Some error happened'; + + const logToConsole = ReactFiberErrorDialog.showErrorDialog({ + ...capturedErrorDefaults, + error: message, + }); + + expect(ExceptionsManager.handleException.mock.calls.length).toBe(1); + const errorArg = ExceptionsManager.handleException.mock.calls[0][0]; + const isFatalArg = ExceptionsManager.handleException.mock.calls[0][1]; + expect(errorArg).toBeInstanceOf(Error); + expect(errorArg).toHaveProperty( + 'message', + message + + '\n\n' + + 'This error is located at:' + + capturedErrorDefaults.componentStack, + ); + expect(isFatalArg).toBe(false); + expect(logToConsole).toBe(false); + }); + + test('reports "Unspecified error" if error is null', () => { + const logToConsole = ReactFiberErrorDialog.showErrorDialog({ + ...capturedErrorDefaults, + error: null, + }); + + expect(ExceptionsManager.handleException.mock.calls.length).toBe(1); + const errorArg = ExceptionsManager.handleException.mock.calls[0][0]; + const isFatalArg = ExceptionsManager.handleException.mock.calls[0][1]; + expect(errorArg).toBeInstanceOf(Error); + expect(errorArg).toHaveProperty( + 'message', + 'Unspecified error at:' + capturedErrorDefaults.componentStack, + ); + expect(isFatalArg).toBe(false); + expect(logToConsole).toBe(false); + }); + }); +}); diff --git a/Libraries/ReactPrivate/ReactNativePrivateInterface.js b/Libraries/ReactPrivate/ReactNativePrivateInterface.js index 99e978864b..82634a6999 100644 --- a/Libraries/ReactPrivate/ReactNativePrivateInterface.js +++ b/Libraries/ReactPrivate/ReactNativePrivateInterface.js @@ -40,4 +40,7 @@ module.exports = { get flattenStyle() { return require('../StyleSheet/flattenStyle'); }, + get ReactFiberErrorDialog() { + return require('../Core/ReactFiberErrorDialog'); + }, };