diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fb939557..6f7eb96d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Add support for automatic capturing of unhandled Promise rejection crashes ([#1014](https://github.com/Instabug/Instabug-React-Native/pull/1014)). - Add new strings (`StringKey.discardAlertStay` and `StringKey.discardAlertDiscard`) for overriding the discard alert buttons for consistency between iOS and Android ([#1001](https://github.com/Instabug/Instabug-React-Native/pull/1001)). - Add a new string (`StringKey.reproStepsListItemNumberingTitle`) for overriding the repro steps list item (screen) title for consistency between iOS and Android ([#1002](https://github.com/Instabug/Instabug-React-Native/pull/1002)). - Add support for RN version 0.73 by updating the `build.gradle` file with the `namespace` ([#1004])(https://github.com/Instabug/Instabug-React-Native/pull/1004) diff --git a/examples/default/metro.config.js b/examples/default/metro.config.js index 907b4e36d..7b7bf874d 100644 --- a/examples/default/metro.config.js +++ b/examples/default/metro.config.js @@ -5,7 +5,15 @@ const exclusionList = require('metro-config/src/defaults/exclusionList'); const root = path.resolve(__dirname, '../..'); const pkg = require(path.join(root, 'package.json')); const peerDependencies = Object.keys(pkg.peerDependencies); -const modules = [...peerDependencies, '@babel/runtime']; +const modules = [ + ...peerDependencies, + '@babel/runtime', + + // We need to exclude the `promise` package in the root node_modules directory + // to be able to track unhandled Promise rejections on the correct example app + // Promise object. + 'promise', +]; module.exports = { watchFolders: [root], diff --git a/examples/default/src/screens/CrashReportingScreen.tsx b/examples/default/src/screens/CrashReportingScreen.tsx index ee01d5de8..3d805185e 100644 --- a/examples/default/src/screens/CrashReportingScreen.tsx +++ b/examples/default/src/screens/CrashReportingScreen.tsx @@ -22,6 +22,13 @@ export const CrashReportingScreen: React.FC = () => { } }} /> + { + Promise.reject(new Error('Unhandled Promise Rejection from Instabug Test App')); + Alert.alert('Crash report sent!'); + }} + /> ); }; diff --git a/src/modules/Instabug.ts b/src/modules/Instabug.ts index 4860fe4cd..a5ca02ac3 100644 --- a/src/modules/Instabug.ts +++ b/src/modules/Instabug.ts @@ -36,6 +36,7 @@ import InstabugUtils, { stringifyIfNotString, } from '../utils/InstabugUtils'; import * as NetworkLogger from './NetworkLogger'; +import { captureUnhandledRejections } from '../utils/UnhandledRejectionTracking'; let _currentScreen: string | null = null; let _lastScreen: string | null = null; @@ -87,6 +88,7 @@ export const start = (token: string, invocationEvents: invocationEvent[] | Invoc */ export const init = (config: InstabugConfig) => { InstabugUtils.captureJsErrors(); + captureUnhandledRejections(); NetworkLogger.setEnabled(true); NativeInstabug.init( diff --git a/src/promise.d.ts b/src/promise.d.ts new file mode 100644 index 000000000..9f8ad06a4 --- /dev/null +++ b/src/promise.d.ts @@ -0,0 +1,11 @@ +declare module 'promise/setimmediate/rejection-tracking' { + export interface RejectionTrackingOptions { + allRejections?: boolean; + whitelist?: Function[]; + onUnhandled?: (id: number, error: unknown) => void; + onHandled?: (id: number, error: unknown) => void; + } + + export function enable(options?: RejectionTrackingOptions): void; + export function disable(): void; +} diff --git a/src/utils/UnhandledRejectionTracking.ts b/src/utils/UnhandledRejectionTracking.ts new file mode 100644 index 000000000..9bc550671 --- /dev/null +++ b/src/utils/UnhandledRejectionTracking.ts @@ -0,0 +1,114 @@ +import tracking, { RejectionTrackingOptions } from 'promise/setimmediate/rejection-tracking'; +import { sendCrashReport } from './InstabugUtils'; +import { NativeCrashReporting } from '../native/NativeCrashReporting'; + +export interface HermesInternalType { + enablePromiseRejectionTracker?: (options?: RejectionTrackingOptions) => void; + hasPromise?: () => boolean; +} + +/** + * A typed version of the `HermesInternal` global object with the properties + * we use. + */ +function _getHermes(): HermesInternalType | null { + return (global as any).HermesInternal; +} + +/** + * Checks whether the Promise object is provided by Hermes. + * + * @returns whether the `Promise` object is provided by Hermes. + */ +function _isHermesPromise() { + const hermes = _getHermes(); + const hasPromise = hermes?.hasPromise?.() === true; + const canTrack = hermes?.enablePromiseRejectionTracker != null; + + return hasPromise && canTrack; +} + +/** + * Enables unhandled Promise rejection tracking in Hermes. + * + * @param options Rejection tracking options. + */ +function _enableHermesRejectionTracking(options?: RejectionTrackingOptions) { + const hermes = _getHermes(); + + hermes!.enablePromiseRejectionTracker!(options); +} + +/** + * Enables unhandled Promise rejection tracking in the default `promise` polyfill. + * + * @param options Rejection tracking options. + */ +function _enableDefaultRejectionTracking(options?: RejectionTrackingOptions) { + tracking.enable(options); +} + +/** + * Tracks whether an unhandled Promise rejection happens and reports it. + */ +export function captureUnhandledRejections() { + const options: RejectionTrackingOptions = { + allRejections: true, + onUnhandled: _onUnhandled, + }; + + if (_isHermesPromise()) { + _enableHermesRejectionTracking(options); + } else { + _enableDefaultRejectionTracking(options); + } +} + +/** + * The callback passed in the rejection tracking options to report unhandled + * Promise rejection + */ +function _onUnhandled(id: number, rejection: unknown) { + _originalOnUnhandled(id, rejection); + + if (__DEV__) { + return; + } + + if (rejection instanceof Error) { + sendCrashReport(rejection, NativeCrashReporting.sendHandledJSCrash); + } +} + +/* istanbul ignore next */ +/** + * The default unhandled promise rejection handler set by React Native. + * + * In fact, this is copied from the React Native repo but modified to work well + * with our static analysis setup. + * + * https://github.com/facebook/react-native/blob/f2447e6048a6b519c3333767d950dbf567149b75/packages/react-native/Libraries/promiseRejectionTrackingOptions.js#L15-L49 + */ +function _originalOnUnhandled(id: number, rejection: unknown = {}) { + let message: string; + let stack: string | undefined; + + const stringValue = Object.prototype.toString.call(rejection); + if (stringValue === '[object Error]') { + message = Error.prototype.toString.call(rejection); + const error = rejection as Error; + stack = error.stack; + } else { + try { + message = require('pretty-format')(rejection); + } catch { + message = typeof rejection === 'string' ? rejection : JSON.stringify(rejection); + } + } + + const warning = + `Possible Unhandled Promise Rejection (id: ${id}):\n` + + `${message ?? ''}\n` + + (stack == null ? '' : stack); + console.warn(warning); +} diff --git a/test/mocks/mockHermesInternal.ts b/test/mocks/mockHermesInternal.ts new file mode 100644 index 000000000..72811488b --- /dev/null +++ b/test/mocks/mockHermesInternal.ts @@ -0,0 +1,14 @@ +import type { HermesInternalType } from '../../src/utils/UnhandledRejectionTracking'; + +export function mockHermesInternal(hermes: HermesInternalType) { + const original = (global as any).HermesInternal; + + // Using Object.defineProperty to avoid TypeScript errors + Object.defineProperty(global, 'HermesInternal', { value: hermes, writable: true }); + + return { + mockRestore: () => { + Object.defineProperty(global, 'HermesInternal', { value: original, writable: true }); + }, + }; +} diff --git a/test/mocks/mockPromiseRejectionTracking.ts b/test/mocks/mockPromiseRejectionTracking.ts new file mode 100644 index 000000000..95023ae6e --- /dev/null +++ b/test/mocks/mockPromiseRejectionTracking.ts @@ -0,0 +1 @@ +jest.mock('promise/setimmediate/rejection-tracking'); diff --git a/test/setup.ts b/test/setup.ts index f18030d28..698cb7ad7 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,4 +1,5 @@ import './mocks/mockNativeModules'; +import './mocks/mockPromiseRejectionTracking'; import './mocks/mockParseErrorStackLib'; import { Platform } from 'react-native'; diff --git a/test/utils/UnhandledRejectionTracking.spec.ts b/test/utils/UnhandledRejectionTracking.spec.ts new file mode 100644 index 000000000..7afffb936 --- /dev/null +++ b/test/utils/UnhandledRejectionTracking.spec.ts @@ -0,0 +1,97 @@ +/// + +import tracking from 'promise/setimmediate/rejection-tracking'; +import { captureUnhandledRejections } from '../../src/utils/UnhandledRejectionTracking'; +import { mockHermesInternal } from '../mocks/mockHermesInternal'; +import { mockDevMode } from '../mocks/mockDevMode'; +import { mocked } from 'jest-mock'; +import { NativeCrashReporting } from '../../src/native/NativeCrashReporting'; + +it('tracks Promise rejections when using Hermes', () => { + const enablePromiseRejectionTracker = jest.fn(); + + const mHermes = mockHermesInternal({ + hasPromise: () => true, + enablePromiseRejectionTracker, + }); + + captureUnhandledRejections(); + + expect(enablePromiseRejectionTracker).toBeCalledTimes(1); + expect(enablePromiseRejectionTracker).toBeCalledWith({ + allRejections: true, + onUnhandled: expect.any(Function), + }); + + mHermes.mockRestore(); +}); + +it('tracks Promise rejections when using `promise` polyfill', () => { + captureUnhandledRejections(); + + expect(tracking.enable).toBeCalledTimes(1); + expect(tracking.enable).toBeCalledWith({ + allRejections: true, + onUnhandled: expect.any(Function), + }); +}); + +it('reports unhandled Promise rejections in release mode', () => { + const mockDev = mockDevMode(false); + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const rejection = new Error('something went wrong'); + const id = 123; + + // Simulate an immediate unhandled promise rejection + mocked(tracking.enable).mockImplementationOnce((options) => { + options?.onUnhandled?.(id, rejection); + }); + + captureUnhandledRejections(); + + expect(NativeCrashReporting.sendHandledJSCrash).toBeCalledTimes(1); + + mockDev.mockRestore(); + consoleWarnSpy.mockRestore(); +}); + +it('does not report unhandled Promise rejections in dev mode', () => { + const mockDev = mockDevMode(true); + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const id = 123; + const rejection = new TypeError("Couldn't fetch data"); + + // Simulate an immediate unhandled promise rejection + mocked(tracking.enable).mockImplementationOnce((options) => { + options?.onUnhandled?.(id, rejection); + }); + + captureUnhandledRejections(); + + expect(NativeCrashReporting.sendHandledJSCrash).not.toBeCalled(); + + mockDev.mockRestore(); + consoleWarnSpy.mockRestore(); +}); + +it('does not report non-error unhandled Promise rejections', () => { + const mockDev = mockDevMode(true); + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const id = 123; + const rejection = 'something went wrong'; + + // Simulate an immediate unhandled promise rejection + mocked(tracking.enable).mockImplementationOnce((options) => { + options?.onUnhandled?.(id, rejection); + }); + + captureUnhandledRejections(); + + expect(NativeCrashReporting.sendHandledJSCrash).not.toBeCalled(); + + mockDev.mockRestore(); + consoleWarnSpy.mockRestore(); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json index 63fd9d48d..78dc5747c 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "include": ["test/**/*"] + "include": ["test/**/*", "src/**/*"] }