Skip to content

Commit 62be19a

Browse files
a7medevHeshamMegid
authored andcommitted
feat: support unhandled promise rejection crashes (#1014)
Jira ID: MOB-12418
1 parent 8f8afb0 commit 62be19a

File tree

11 files changed

+258
-2
lines changed

11 files changed

+258
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Added
66

7+
- Add support for automatic capturing of unhandled Promise rejection crashes ([#1014](https://github.com/Instabug/Instabug-React-Native/pull/1014)).
78
- 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)).
89
- 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)).
910
- 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))

examples/default/metro.config.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,15 @@ const exclusionList = require('metro-config/src/defaults/exclusionList');
55
const root = path.resolve(__dirname, '../..');
66
const pkg = require(path.join(root, 'package.json'));
77
const peerDependencies = Object.keys(pkg.peerDependencies);
8-
const modules = [...peerDependencies, '@babel/runtime'];
8+
const modules = [
9+
...peerDependencies,
10+
'@babel/runtime',
11+
12+
// We need to exclude the `promise` package in the root node_modules directory
13+
// to be able to track unhandled Promise rejections on the correct example app
14+
// Promise object.
15+
'promise',
16+
];
917

1018
module.exports = {
1119
watchFolders: [root],

examples/default/src/screens/CrashReportingScreen.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ export const CrashReportingScreen: React.FC = () => {
2222
}
2323
}}
2424
/>
25+
<ListTile
26+
title="Reject an Unhandled Promise"
27+
onPress={() => {
28+
Promise.reject(new Error('Unhandled Promise Rejection from Instabug Test App'));
29+
Alert.alert('Crash report sent!');
30+
}}
31+
/>
2532
</Screen>
2633
);
2734
};

src/modules/Instabug.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import InstabugUtils, {
3636
stringifyIfNotString,
3737
} from '../utils/InstabugUtils';
3838
import * as NetworkLogger from './NetworkLogger';
39+
import { captureUnhandledRejections } from '../utils/UnhandledRejectionTracking';
3940

4041
let _currentScreen: string | null = null;
4142
let _lastScreen: string | null = null;
@@ -87,6 +88,7 @@ export const start = (token: string, invocationEvents: invocationEvent[] | Invoc
8788
*/
8889
export const init = (config: InstabugConfig) => {
8990
InstabugUtils.captureJsErrors();
91+
captureUnhandledRejections();
9092
NetworkLogger.setEnabled(true);
9193

9294
NativeInstabug.init(

src/promise.d.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
declare module 'promise/setimmediate/rejection-tracking' {
2+
export interface RejectionTrackingOptions {
3+
allRejections?: boolean;
4+
whitelist?: Function[];
5+
onUnhandled?: (id: number, error: unknown) => void;
6+
onHandled?: (id: number, error: unknown) => void;
7+
}
8+
9+
export function enable(options?: RejectionTrackingOptions): void;
10+
export function disable(): void;
11+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import tracking, { RejectionTrackingOptions } from 'promise/setimmediate/rejection-tracking';
2+
import { sendCrashReport } from './InstabugUtils';
3+
import { NativeCrashReporting } from '../native/NativeCrashReporting';
4+
5+
export interface HermesInternalType {
6+
enablePromiseRejectionTracker?: (options?: RejectionTrackingOptions) => void;
7+
hasPromise?: () => boolean;
8+
}
9+
10+
/**
11+
* A typed version of the `HermesInternal` global object with the properties
12+
* we use.
13+
*/
14+
function _getHermes(): HermesInternalType | null {
15+
return (global as any).HermesInternal;
16+
}
17+
18+
/**
19+
* Checks whether the Promise object is provided by Hermes.
20+
*
21+
* @returns whether the `Promise` object is provided by Hermes.
22+
*/
23+
function _isHermesPromise() {
24+
const hermes = _getHermes();
25+
const hasPromise = hermes?.hasPromise?.() === true;
26+
const canTrack = hermes?.enablePromiseRejectionTracker != null;
27+
28+
return hasPromise && canTrack;
29+
}
30+
31+
/**
32+
* Enables unhandled Promise rejection tracking in Hermes.
33+
*
34+
* @param options Rejection tracking options.
35+
*/
36+
function _enableHermesRejectionTracking(options?: RejectionTrackingOptions) {
37+
const hermes = _getHermes();
38+
39+
hermes!.enablePromiseRejectionTracker!(options);
40+
}
41+
42+
/**
43+
* Enables unhandled Promise rejection tracking in the default `promise` polyfill.
44+
*
45+
* @param options Rejection tracking options.
46+
*/
47+
function _enableDefaultRejectionTracking(options?: RejectionTrackingOptions) {
48+
tracking.enable(options);
49+
}
50+
51+
/**
52+
* Tracks whether an unhandled Promise rejection happens and reports it.
53+
*/
54+
export function captureUnhandledRejections() {
55+
const options: RejectionTrackingOptions = {
56+
allRejections: true,
57+
onUnhandled: _onUnhandled,
58+
};
59+
60+
if (_isHermesPromise()) {
61+
_enableHermesRejectionTracking(options);
62+
} else {
63+
_enableDefaultRejectionTracking(options);
64+
}
65+
}
66+
67+
/**
68+
* The callback passed in the rejection tracking options to report unhandled
69+
* Promise rejection
70+
*/
71+
function _onUnhandled(id: number, rejection: unknown) {
72+
_originalOnUnhandled(id, rejection);
73+
74+
if (__DEV__) {
75+
return;
76+
}
77+
78+
if (rejection instanceof Error) {
79+
sendCrashReport(rejection, NativeCrashReporting.sendHandledJSCrash);
80+
}
81+
}
82+
83+
/* istanbul ignore next */
84+
/**
85+
* The default unhandled promise rejection handler set by React Native.
86+
*
87+
* In fact, this is copied from the React Native repo but modified to work well
88+
* with our static analysis setup.
89+
*
90+
* https://github.com/facebook/react-native/blob/f2447e6048a6b519c3333767d950dbf567149b75/packages/react-native/Libraries/promiseRejectionTrackingOptions.js#L15-L49
91+
*/
92+
function _originalOnUnhandled(id: number, rejection: unknown = {}) {
93+
let message: string;
94+
let stack: string | undefined;
95+
96+
const stringValue = Object.prototype.toString.call(rejection);
97+
if (stringValue === '[object Error]') {
98+
message = Error.prototype.toString.call(rejection);
99+
const error = rejection as Error;
100+
stack = error.stack;
101+
} else {
102+
try {
103+
message = require('pretty-format')(rejection);
104+
} catch {
105+
message = typeof rejection === 'string' ? rejection : JSON.stringify(rejection);
106+
}
107+
}
108+
109+
const warning =
110+
`Possible Unhandled Promise Rejection (id: ${id}):\n` +
111+
`${message ?? ''}\n` +
112+
(stack == null ? '' : stack);
113+
console.warn(warning);
114+
}

test/mocks/mockHermesInternal.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { HermesInternalType } from '../../src/utils/UnhandledRejectionTracking';
2+
3+
export function mockHermesInternal(hermes: HermesInternalType) {
4+
const original = (global as any).HermesInternal;
5+
6+
// Using Object.defineProperty to avoid TypeScript errors
7+
Object.defineProperty(global, 'HermesInternal', { value: hermes, writable: true });
8+
9+
return {
10+
mockRestore: () => {
11+
Object.defineProperty(global, 'HermesInternal', { value: original, writable: true });
12+
},
13+
};
14+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
jest.mock('promise/setimmediate/rejection-tracking');

test/setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import './mocks/mockNativeModules';
2+
import './mocks/mockPromiseRejectionTracking';
23
import './mocks/mockParseErrorStackLib';
34

45
import { Platform } from 'react-native';
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/// <reference path="../../src/promise.d.ts" />
2+
3+
import tracking from 'promise/setimmediate/rejection-tracking';
4+
import { captureUnhandledRejections } from '../../src/utils/UnhandledRejectionTracking';
5+
import { mockHermesInternal } from '../mocks/mockHermesInternal';
6+
import { mockDevMode } from '../mocks/mockDevMode';
7+
import { mocked } from 'jest-mock';
8+
import { NativeCrashReporting } from '../../src/native/NativeCrashReporting';
9+
10+
it('tracks Promise rejections when using Hermes', () => {
11+
const enablePromiseRejectionTracker = jest.fn();
12+
13+
const mHermes = mockHermesInternal({
14+
hasPromise: () => true,
15+
enablePromiseRejectionTracker,
16+
});
17+
18+
captureUnhandledRejections();
19+
20+
expect(enablePromiseRejectionTracker).toBeCalledTimes(1);
21+
expect(enablePromiseRejectionTracker).toBeCalledWith({
22+
allRejections: true,
23+
onUnhandled: expect.any(Function),
24+
});
25+
26+
mHermes.mockRestore();
27+
});
28+
29+
it('tracks Promise rejections when using `promise` polyfill', () => {
30+
captureUnhandledRejections();
31+
32+
expect(tracking.enable).toBeCalledTimes(1);
33+
expect(tracking.enable).toBeCalledWith({
34+
allRejections: true,
35+
onUnhandled: expect.any(Function),
36+
});
37+
});
38+
39+
it('reports unhandled Promise rejections in release mode', () => {
40+
const mockDev = mockDevMode(false);
41+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
42+
43+
const rejection = new Error('something went wrong');
44+
const id = 123;
45+
46+
// Simulate an immediate unhandled promise rejection
47+
mocked(tracking.enable).mockImplementationOnce((options) => {
48+
options?.onUnhandled?.(id, rejection);
49+
});
50+
51+
captureUnhandledRejections();
52+
53+
expect(NativeCrashReporting.sendHandledJSCrash).toBeCalledTimes(1);
54+
55+
mockDev.mockRestore();
56+
consoleWarnSpy.mockRestore();
57+
});
58+
59+
it('does not report unhandled Promise rejections in dev mode', () => {
60+
const mockDev = mockDevMode(true);
61+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
62+
63+
const id = 123;
64+
const rejection = new TypeError("Couldn't fetch data");
65+
66+
// Simulate an immediate unhandled promise rejection
67+
mocked(tracking.enable).mockImplementationOnce((options) => {
68+
options?.onUnhandled?.(id, rejection);
69+
});
70+
71+
captureUnhandledRejections();
72+
73+
expect(NativeCrashReporting.sendHandledJSCrash).not.toBeCalled();
74+
75+
mockDev.mockRestore();
76+
consoleWarnSpy.mockRestore();
77+
});
78+
79+
it('does not report non-error unhandled Promise rejections', () => {
80+
const mockDev = mockDevMode(true);
81+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
82+
83+
const id = 123;
84+
const rejection = 'something went wrong';
85+
86+
// Simulate an immediate unhandled promise rejection
87+
mocked(tracking.enable).mockImplementationOnce((options) => {
88+
options?.onUnhandled?.(id, rejection);
89+
});
90+
91+
captureUnhandledRejections();
92+
93+
expect(NativeCrashReporting.sendHandledJSCrash).not.toBeCalled();
94+
95+
mockDev.mockRestore();
96+
consoleWarnSpy.mockRestore();
97+
});

0 commit comments

Comments
 (0)