Skip to content

Commit

Permalink
fix: Polyfill the promise library to permanently fix unhandled reject…
Browse files Browse the repository at this point in the history
…ions (#1984)

* polyfill test

* remove promise warning

* fix

* changelog

* should fail e2e

* should fail e2e not build lol

* remove package resolution

* re-enable and document option

* ref: Clean and refactor from code review

* fix changelog

Co-authored-by: Manoel Aranda Neto <marandaneto@gmail.com>
  • Loading branch information
jennmueng and marandaneto committed Jan 10, 2022
1 parent 21434ea commit 1b53983
Show file tree
Hide file tree
Showing 6 changed files with 79 additions and 72 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## Unreleased

- fix: Polyfill the promise library to permanently fix unhandled rejections #1984

## 3.2.10

- fix: Do not crash if androidx.core isn't available on Android #1981
Expand Down
3 changes: 0 additions & 3 deletions sample/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,6 @@
},
"name": "sample",
"private": true,
"resolutions": {
"promise": "^8.1.0"
},
"scripts": {
"android": "react-native run-android",
"ios": "react-native run-ios",
Expand Down
98 changes: 61 additions & 37 deletions src/js/integrations/reactnativeerrorhandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ReactNativeClient } from "../client";
interface ReactNativeErrorHandlersOptions {
onerror: boolean;
onunhandledrejection: boolean;
patchGlobalPromise: boolean;
}

interface PromiseRejectionTrackingOptions {
Expand All @@ -35,10 +36,11 @@ export class ReactNativeErrorHandlers implements Integration {
private readonly _options: ReactNativeErrorHandlersOptions;

/** Constructor */
public constructor(options?: ReactNativeErrorHandlersOptions) {
public constructor(options?: Partial<ReactNativeErrorHandlersOptions>) {
this._options = {
onerror: true,
onunhandledrejection: true,
patchGlobalPromise: true,
...options,
};
}
Expand All @@ -56,45 +58,50 @@ export class ReactNativeErrorHandlers implements Integration {
*/
private _handleUnhandledRejections(): void {
if (this._options.onunhandledrejection) {
/*
In newer RN versions >=0.63, the global promise is not the same reference as the one imported from the promise library.
This is due to a version mismatch between promise versions. The version will need to be fixed with a package resolution.
We first run a check and show a warning if needed.
*/
this._checkPromiseVersion();

const tracking: {
disable: () => void;
enable: (arg: unknown) => void;
// eslint-disable-next-line @typescript-eslint/no-var-requires,import/no-extraneous-dependencies
} = require("promise/setimmediate/rejection-tracking");

const promiseRejectionTrackingOptions = this._getPromiseRejectionTrackingOptions();

tracking.disable();
tracking.enable({
allRejections: true,
onUnhandled: (id: string, error: Error) => {
if (__DEV__) {
promiseRejectionTrackingOptions.onUnhandled(id, error);
}
if (this._options.patchGlobalPromise) {
this._polyfillPromise();
}

getCurrentHub().captureException(error, {
data: { id },
originalException: error,
});
},
onHandled: (id: string) => {
promiseRejectionTrackingOptions.onHandled(id);
},
});
this._attachUnhandledRejectionHandler();
this._checkPromiseAndWarn();
}
}
/**
* Gets the promise rejection handlers, tries to get React Native's default one but otherwise will default to console.logging unhandled rejections.
* Polyfill the global promise instance with one we can be sure that we can attach the tracking to.
*
* In newer RN versions >=0.63, the global promise is not the same reference as the one imported from the promise library.
* This is due to a version mismatch between promise versions.
* Originally we tried a solution where we would have you put a package resolution to ensure the promise instances match. However,
* - Using a package resolution requires the you to manually troubleshoot.
* - The package resolution fix no longer works with 0.67 on iOS Hermes.
*/
private _getPromiseRejectionTrackingOptions(): PromiseRejectionTrackingOptions {
return {
private _polyfillPromise(): void {
/* eslint-disable import/no-extraneous-dependencies,@typescript-eslint/no-var-requires*/
const {
polyfillGlobal,
} = require("react-native/Libraries/Utilities/PolyfillFunctions");

// Below, we follow the exact way React Native initializes its promise library, and we globally replace it.
const Promise = require("promise/setimmediate/es6-extensions");

// As of RN 0.67 only done and finally are used
require("promise/setimmediate/done");
require("promise/setimmediate/finally");

polyfillGlobal("Promise", () => Promise);
/* eslint-enable import/no-extraneous-dependencies,@typescript-eslint/no-var-requires */
}
/**
* Attach the unhandled rejection handler
*/
private _attachUnhandledRejectionHandler(): void {
const tracking: {
disable: () => void;
enable: (arg: unknown) => void;
// eslint-disable-next-line import/no-extraneous-dependencies,@typescript-eslint/no-var-requires
} = require("promise/setimmediate/rejection-tracking");

const promiseRejectionTrackingOptions: PromiseRejectionTrackingOptions = {
onUnhandled: (id, rejection = {}) => {
// eslint-disable-next-line no-console
console.warn(
Expand All @@ -110,14 +117,31 @@ export class ReactNativeErrorHandlers implements Integration {
);
},
};

tracking.enable({
allRejections: true,
onUnhandled: (id: string, error: Error) => {
if (__DEV__) {
promiseRejectionTrackingOptions.onUnhandled(id, error);
}

getCurrentHub().captureException(error, {
data: { id },
originalException: error,
});
},
onHandled: (id: string) => {
promiseRejectionTrackingOptions.onHandled(id);
},
});
}
/**
* Checks if the promise is the same one or not, if not it will warn the user
*/
private _checkPromiseVersion(): void {
private _checkPromiseAndWarn(): void {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires,import/no-extraneous-dependencies
const Promise = require("promise/setimmediate/core");
const Promise = require("promise/setimmediate/es6-extensions");

const _global = getGlobalObject<{ Promise: typeof Promise }>();

Expand Down
10 changes: 10 additions & 0 deletions src/js/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ export interface ReactNativeOptions extends BrowserOptions {
* @deprecated Use `Sentry.configureScope(...)`
*/
initialScope?: CaptureContext;

/**
* When enabled, Sentry will overwrite the global Promise instance to ensure that unhandled rejections are correctly tracked.
* If you run into issues with Promise polyfills such as `core-js`, make sure you polyfill after Sentry is initialized.
* Read more at https://docs.sentry.io/platforms/react-native/troubleshooting/#unhandled-promise-rejections
*
* When disabled, this option will not disable unhandled rejection tracking. Set `onunhandledrejection: false` on the `ReactNativeErrorHandlers` integration instead.
* @default true
*/
patchGlobalPromise?: boolean;
}

export interface ReactNativeWrapperOptions {
Expand Down
5 changes: 4 additions & 1 deletion src/js/sdk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const DEFAULT_OPTIONS: ReactNativeOptions = {
autoInitializeNativeSdk: true,
enableAutoPerformanceTracking: true,
enableOutOfMemoryTracking: true,
patchGlobalPromise: true,
};

/**
Expand All @@ -52,7 +53,9 @@ export function init(passedOptions: ReactNativeOptions): void {

if (options.defaultIntegrations === undefined) {
options.defaultIntegrations = [
new ReactNativeErrorHandlers(),
new ReactNativeErrorHandlers({
patchGlobalPromise: options.patchGlobalPromise,
}),
new Release(),
...defaultIntegrations.filter(
(i) => !IGNORED_DEFAULT_INTEGRATIONS.includes(i.name)
Expand Down
31 changes: 0 additions & 31 deletions test/integrations/reactnativeerrorhandlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,8 @@ jest.mock("@sentry/utils", () => {
};
});

jest.mock("promise/setimmediate/core", () => {
return {};
});

import { getCurrentHub } from "@sentry/core";
import { Severity } from "@sentry/types";
import { getGlobalObject, logger } from "@sentry/utils";

import { ReactNativeErrorHandlers } from "../../src/js/integrations/reactnativeerrorhandlers";

Expand Down Expand Up @@ -107,30 +102,4 @@ describe("ReactNativeErrorHandlers", () => {
expect(event.exception?.values?.[0].mechanism?.type).toBe("generic");
});
});

describe("onUnhandledRejection", () => {
it("Warns if promise instances are different", () => {
const _global = getGlobalObject<{ Promise: any }>();

_global.Promise = {};

const inst = new ReactNativeErrorHandlers();
inst.setupOnce();

// eslint-disable-next-line @typescript-eslint/unbound-method
expect(logger.warn).toBeCalled();
});

it("Does not warn if promise instances match", () => {
const _global = getGlobalObject<{ Promise: any }>();

_global.Promise = require("promise/setimmediate/core");

const inst = new ReactNativeErrorHandlers();
inst.setupOnce();

// eslint-disable-next-line @typescript-eslint/unbound-method
expect(logger.warn).not.toBeCalled();
});
});
});

0 comments on commit 1b53983

Please sign in to comment.