Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion examples/default/metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
7 changes: 7 additions & 0 deletions examples/default/src/screens/CrashReportingScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ export const CrashReportingScreen: React.FC = () => {
}
}}
/>
<ListTile
title="Reject an Unhandled Promise"
onPress={() => {
Promise.reject(new Error('Unhandled Promise Rejection from Instabug Test App'));
Alert.alert('Crash report sent!');
}}
/>
</Screen>
);
};
2 changes: 2 additions & 0 deletions src/modules/Instabug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
11 changes: 11 additions & 0 deletions src/promise.d.ts
Original file line number Diff line number Diff line change
@@ -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;
}
114 changes: 114 additions & 0 deletions src/utils/UnhandledRejectionTracking.ts
Original file line number Diff line number Diff line change
@@ -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);
}
14 changes: 14 additions & 0 deletions test/mocks/mockHermesInternal.ts
Original file line number Diff line number Diff line change
@@ -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 });
},
};
}
1 change: 1 addition & 0 deletions test/mocks/mockPromiseRejectionTracking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
jest.mock('promise/setimmediate/rejection-tracking');
1 change: 1 addition & 0 deletions test/setup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import './mocks/mockNativeModules';
import './mocks/mockPromiseRejectionTracking';
import './mocks/mockParseErrorStackLib';

import { Platform } from 'react-native';
Expand Down
97 changes: 97 additions & 0 deletions test/utils/UnhandledRejectionTracking.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/// <reference path="../../src/promise.d.ts" />

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();
});
2 changes: 1 addition & 1 deletion tsconfig.test.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["test/**/*"]
"include": ["test/**/*", "src/**/*"]
}