diff --git a/.github/scripts/android-js-fatal.sh b/.github/scripts/android-js-fatal.sh index e46b306b..bfff06a8 100644 --- a/.github/scripts/android-js-fatal.sh +++ b/.github/scripts/android-js-fatal.sh @@ -1,13 +1,21 @@ #!/usr/bin/env bash # Asserts a fatal JS error produces one report, no duplicate from the rethrown JavascriptException, and no 5 s wait. +# MODE=js-fatal throws from a timer, MODE=render-error throws while rendering, outside any error boundary. set -euo pipefail +MODE="${MODE:-js-fatal}" +case "$MODE" in + js-fatal) MESSAGE_PREFIX="BT_CI_JS_FATAL" ;; + render-error) MESSAGE_PREFIX="BT_CI_RENDER_ERROR" ;; + *) echo "::error::MODE must be js-fatal or render-error, got '$MODE'"; exit 2 ;; +esac + PACKAGE="com.reactnative" ACTIVITY="$PACKAGE/.MainActivity" APK="examples/sdk/reactNative/android/app/build/outputs/apk/release/app-release.apk" -MARKER="ci-js-fatal-$(date +%s)" -TRIGGER_URL="backtrace-example://ci-js-fatal?marker=$MARKER" -LOGCAT_OUT="/tmp/js-fatal-logcat.txt" +MARKER="ci-$MODE-$(date +%s)" +TRIGGER_URL="backtrace-example://ci-$MODE?marker=$MARKER" +LOGCAT_OUT="/tmp/$MODE-logcat.txt" FORWARD_BOUND_S=7 EXIT_BOUND_S=2 @@ -46,7 +54,7 @@ wait_for_log "BT_CI_DRIVER_ARMED" 120 PID="$(adb shell pidof "$PACKAGE" | tr -d '\r')" adb shell am start -n "$ACTIVITY" -a android.intent.action.VIEW -d "'$TRIGGER_URL'" >/dev/null -echo "fatal JS error fired: $MARKER, pid $PID" +echo "$MODE fired: $MARKER, pid $PID" for _ in $(seq 1 30); do if ! adb shell pidof "$PACKAGE" >/dev/null 2>&1; then @@ -61,7 +69,7 @@ fi sleep 1 adb logcat -d -v epoch > "$LOGCAT_OUT" -FIRED_AT="$(awk '/BT_CI_DRIVER firing: backtrace-example:\/\/ci-js-fatal/ { print $1; exit }' "$LOGCAT_OUT")" +FIRED_AT="$(awk -v url="BT_CI_DRIVER firing: backtrace-example://ci-$MODE" 'index($0, url) { print $1; exit }' "$LOGCAT_OUT")" RETHROWN_AT="$(awk -v pid="$PID" '$2 == pid && /AndroidRuntime: FATAL EXCEPTION/ { print $1; exit }' "$LOGCAT_OUT")" DIED_AT="$(grep -F "Process $PACKAGE (pid $PID) has died" "$LOGCAT_OUT" | head -1 | awk '{ print $1 }' || true)" if [ -z "$FIRED_AT" ] || [ -z "$RETHROWN_AT" ] || [ -z "$DIED_AT" ]; then @@ -90,8 +98,12 @@ if [ "$COUNT" != "1" ]; then echo "::error::expected exactly one stored report, found $COUNT" exit 1 fi -if ! printf '%s\n' "$RECORDS" | grep -qF "Error \"BT_CI_JS_FATAL $MARKER\""; then - echo "::error::the stored report is not the fatal JS error" +if ! printf '%s\n' "$RECORDS" | grep -qF "Error \"$MESSAGE_PREFIX $MARKER\" \"Unhandled exception\" fatal=true"; then + echo "::error::the stored report is not the $MODE error, typed as a fatal unhandled exception" + exit 1 +fi +if [ "$MODE" = "render-error" ] && ! printf '%s\n' "$RECORDS" | grep -qF "component-stack"; then + echo "::error::the stored render error report has no component stack" exit 1 fi echo "one report with the JS error, no JavascriptException duplicate, process died ${EXIT_S}s after the rethrow" diff --git a/.github/scripts/ci-crash-driver.js b/.github/scripts/ci-crash-driver.js index 40a67949..a91a7ca4 100644 --- a/.github/scripts/ci-crash-driver.js +++ b/.github/scripts/ci-crash-driver.js @@ -1,5 +1,6 @@ // Appended to the example's index.js by the Android CI workflow. Never committed to the example. -import { Linking } from 'react-native'; +import * as CiReact from 'react'; +import { AppRegistry as CiAppRegistry, Linking } from 'react-native'; Linking.addEventListener('url', ({ url }) => { const fatal = /^backtrace-example:\/\/ci-js-fatal\?marker=([A-Za-z0-9-]+)$/.exec(url ?? ''); @@ -22,10 +23,34 @@ Linking.addEventListener('url', ({ url }) => { BacktraceClient.instance?.database?.on('added', (record) => { if (record.type === 'report') { + const { attributes, classifiers, threads } = record.data; console.log( - `BT_CI_RECORD ${record.data.classifiers?.[0]} ${JSON.stringify(record.data.attributes?.['error.message'] ?? '')}`, + `BT_CI_RECORD ${classifiers?.[0]} ${JSON.stringify(attributes?.['error.message'] ?? '')} ` + + `${JSON.stringify(attributes?.['error.type'] ?? '')} fatal=${attributes?.fatal} ` + + `threads=${Object.keys(threads ?? {}).join(',')}`, ); } }); -console.log('BT_CI_DRIVER_ARMED'); +function CiRenderError({ marker }) { + throw new Error(`BT_CI_RENDER_ERROR ${marker}`); +} + +// Sits outside the example's ErrorBoundary. +function CiRenderErrorRoot({ children }) { + const [marker, setMarker] = CiReact.useState(); + CiReact.useEffect(() => { + const subscription = Linking.addEventListener('url', ({ url }) => { + const match = /^backtrace-example:\/\/ci-render-error\?marker=([A-Za-z0-9-]+)$/.exec(url ?? ''); + if (match) { + console.log(`BT_CI_DRIVER firing: ${url}`); + setTimeout(() => setMarker(match[1]), 0); + } + }); + console.log('BT_CI_DRIVER_ARMED'); + return () => subscription.remove(); + }, []); + return marker ? CiReact.createElement(CiRenderError, { marker }) : children; +} + +CiAppRegistry.setWrapperComponentProvider(() => CiRenderErrorRoot); diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 40ea9353..4aa36e6f 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -113,7 +113,7 @@ jobs: disable-animations: false script: echo "Generated AVD snapshot for caching." - - name: Capture a native crash, single APK then Play-style split install, and a fatal JS error on the emulator + - name: Capture a native crash, single APK then Play-style split install, a fatal JS error and a render error on the emulator uses: reactivecircus/android-emulator-runner@v2 env: BUNDLETOOL_JAR: ${{ runner.temp }}/bundletool.jar @@ -128,6 +128,7 @@ jobs: INSTALL=apk bash .github/scripts/android-native-crash.sh INSTALL=split bash .github/scripts/android-native-crash.sh bash .github/scripts/android-js-fatal.sh + MODE=render-error bash .github/scripts/android-js-fatal.sh - name: Upload crash evidence if: failure() @@ -138,5 +139,6 @@ jobs: /tmp/native-crash-*.dmp /tmp/logcat-*.txt /tmp/js-fatal-logcat.txt + /tmp/render-error-logcat.txt if-no-files-found: warn retention-days: 7 diff --git a/packages/react-native/src/common/exceptionsManagerHelper.ts b/packages/react-native/src/common/exceptionsManagerHelper.ts new file mode 100644 index 00000000..91f3cd14 --- /dev/null +++ b/packages/react-native/src/common/exceptionsManagerHelper.ts @@ -0,0 +1,19 @@ +export interface ExceptionsManager { + handleException(error: unknown, isFatal: boolean): void; +} + +type ExceptionsManagerModule = Partial & { default?: Partial }; + +export function exceptionsManager(): ExceptionsManager | undefined { + let exported: ExceptionsManagerModule | undefined; + try { + // Metro bundles a missing module as optional only when its require sits directly inside a try block. + // eslint-disable-next-line @typescript-eslint/no-var-requires + exported = require('react-native/Libraries/Core/ExceptionsManager'); + } catch { + return undefined; + } + + const manager = typeof exported?.default?.handleException === 'function' ? exported.default : exported; + return typeof manager?.handleException === 'function' ? (manager as ExceptionsManager) : undefined; +} diff --git a/packages/react-native/src/handlers/UnhandledExceptionHandler.ts b/packages/react-native/src/handlers/UnhandledExceptionHandler.ts index 7ab0d836..aca83609 100644 --- a/packages/react-native/src/handlers/UnhandledExceptionHandler.ts +++ b/packages/react-native/src/handlers/UnhandledExceptionHandler.ts @@ -2,6 +2,7 @@ import { BacktraceReport, warnFailure } from '@backtrace/sdk-core'; import { format as prettyFormat } from 'pretty-format'; import { AppState } from 'react-native'; import type { BacktraceClient } from '../BacktraceClient'; +import { exceptionsManager, type ExceptionsManager } from '../common/exceptionsManagerHelper'; import { hermes } from '../common/hermesHelper'; import { CrashReporter } from '../crashReporter/CrashReporter'; import { type ExceptionHandler } from './ExceptionHandler'; @@ -16,8 +17,13 @@ const rejectionTracking = require('promise/setimmediate/rejection-tracking') as }; type GlobalErrorHandler = ReturnType; +type HandleException = ExceptionsManager['handleException']; const FATAL_REPORT_TIMEOUT_MS = 5000; +const COMPONENT_STACK_THREAD = 'component-stack'; + +// Shared across clients: a fatal forwarded after dispose and re-init must not be reported again. +const reportedFatalErrors = new WeakSet(); export class UnhandledExceptionHandler implements ExceptionHandler { protected enabled = true; @@ -26,6 +32,9 @@ export class UnhandledExceptionHandler implements ExceptionHandler { private _rejectionClient?: BacktraceClient; private _installedHandler?: GlobalErrorHandler; private _previousHandler?: GlobalErrorHandler; + private _exceptionsManager?: ExceptionsManager; + private _installedHandleException?: HandleException; + private _previousHandleException?: HandleException; private _reportingManagedError = false; private _reportingRejection = false; private _warning = false; @@ -46,52 +55,7 @@ export class UnhandledExceptionHandler implements ExceptionHandler { throw error; } - const managedClient = this._managedClient; - - if (!this.enabled || !managedClient || this._reportingManagedError) { - // Disposed and re-entered wrappers still forward. - // Exceptions from the application's original handler must keep their original behavior. - previousHandler(error, fatal); - return; - } - - let submission: Promise | undefined; - this._reportingManagedError = true; - try { - // send() can throw before returning a promise. - submission = Promise.resolve( - managedClient.send(error, { - 'error.type': 'Unhandled exception', - fatal, - }), - ).catch(() => undefined); - } catch (err) { - this.warnReportingFailure('failed to report an unhandled error', err); - } finally { - this._reportingManagedError = false; - } - - if (!fatal || !submission) { - previousHandler(error, fatal); - return; - } - - // The previous handler ends the process only in release builds. - if (__DEV__) { - previousHandler(error, fatal); - return; - } - - if (this.isInBackground()) { - // JS timers are paused in the background. - this.markFatalErrorSafely(error); - previousHandler(error, fatal); - return; - } - - // The previous handler ends the process. - const settled = this.settle(submission, FATAL_REPORT_TIMEOUT_MS); - this.queueFatalForwarding(() => settled.then(() => this.forwardFatal(previousHandler, error, fatal))); + this.handleUnhandledError(error, fatal, () => previousHandler(error, fatal)); }; try { @@ -102,6 +66,12 @@ export class UnhandledExceptionHandler implements ExceptionHandler { this._managedClient = undefined; throw err; } + + try { + this.captureRenderErrors(); + } catch (err) { + this.warnReportingFailure('failed to capture render errors outside an error boundary', err); + } } public captureUnhandledPromiseRejections(client: BacktraceClient): void { @@ -164,12 +134,119 @@ export class UnhandledExceptionHandler implements ExceptionHandler { this._installedHandler = undefined; this._previousHandler = undefined; + if ( + this._exceptionsManager && + this._previousHandleException && + this._exceptionsManager.handleException === this._installedHandleException + ) { + this._exceptionsManager.handleException = this._previousHandleException; + } + + this._exceptionsManager = undefined; + this._installedHandleException = undefined; + this._previousHandleException = undefined; + // Promise trackers do not expose ownership. Disabling tracking globally // could disable a newer tracker installed by another SDK. } protected markFatalError: (message: string) => void = () => CrashReporter.markFatalError(); + private captureRenderErrors(): void { + const manager = exceptionsManager(); + if (!manager) { + return; + } + + const previousHandleException = manager.handleException; + const installedHandleException: HandleException = (error, isFatal) => { + const forward = () => previousHandleException.call(manager, error, isFatal); + if (!isFatal || !(isComponentError(error) || reportedFatalErrors.has(error as object))) { + forward(); + return; + } + + this.handleUnhandledError(error, isFatal, forward); + }; + + manager.handleException = installedHandleException; + if (manager.handleException !== installedHandleException) { + throw new Error('ExceptionsManager.handleException is read-only'); + } + + this._exceptionsManager = manager; + this._previousHandleException = previousHandleException; + this._installedHandleException = installedHandleException; + } + + private handleUnhandledError(error: unknown, fatal: boolean | undefined, forward: () => void): void { + if (fatal && reportedFatalErrors.has(error as object)) { + if (!__DEV__) { + // Another handler can delay React Native's native fatal call past the first mark. + this.markFatalErrorSafely(error); + } + forward(); + return; + } + + const managedClient = this._managedClient; + + if (!this.enabled || !managedClient || this._reportingManagedError) { + // Disposed and re-entered wrappers still forward. + // Exceptions from the application's original handler must keep their original behavior. + forward(); + return; + } + + let submission: Promise | undefined; + this._reportingManagedError = true; + try { + // send() can throw before returning a promise. + submission = Promise.resolve(this.sendUnhandledError(managedClient, error, fatal)).catch(() => undefined); + if (fatal && typeof error === 'object' && error !== null) { + reportedFatalErrors.add(error); + } + } catch (err) { + this.warnReportingFailure('failed to report an unhandled error', err); + } finally { + this._reportingManagedError = false; + } + + if (!fatal || !submission) { + forward(); + return; + } + + // The previous handler ends the process only in release builds. + if (__DEV__) { + forward(); + return; + } + + if (this.isInBackground()) { + // JS timers are paused in the background. + this.markFatalErrorSafely(error); + forward(); + return; + } + + // The previous handler ends the process. + const settled = this.settle(submission, FATAL_REPORT_TIMEOUT_MS); + this.queueFatalForwarding(() => settled.then(() => this.forwardFatal(error, forward))); + } + + private sendUnhandledError(client: BacktraceClient, error: unknown, fatal: boolean | undefined) { + const attributes = { 'error.type': 'Unhandled exception', fatal }; + const componentStack = isComponentError(error) ? readComponentStack(error) : undefined; + if (!componentStack) { + return client.send(error as Error, attributes); + } + + return client.send( + new BacktraceReport(error as Error, attributes).addStackTrace(COMPONENT_STACK_THREAD, componentStack), + ); + } + private queueFatalForwarding(forward: () => Promise | void): void { const pending = (this._pendingFatal ?? Promise.resolve()).then(forward).then(() => { if (this._pendingFatal === pending) { @@ -179,10 +256,10 @@ export class UnhandledExceptionHandler implements ExceptionHandler { this._pendingFatal = pending; } - private forwardFatal(previousHandler: GlobalErrorHandler, error: Error, fatal: boolean): void { + private forwardFatal(error: unknown, forward: () => void): void { this.markFatalErrorSafely(error); try { - previousHandler(error, fatal); + forward(); } catch (err) { // React Native's native fallback ends the process only for a throw out of the global handler. this._escalatedError = err; @@ -327,3 +404,21 @@ export class UnhandledExceptionHandler implements ExceptionHandler { } } } + +function isComponentError(error: unknown): boolean { + try { + return (error as { isComponentError?: unknown } | null | undefined)?.isComponentError === true; + } catch { + // Error objects can expose getters that throw. + return false; + } +} + +function readComponentStack(error: unknown): string | undefined { + try { + const componentStack = (error as { componentStack?: unknown }).componentStack; + return typeof componentStack === 'string' ? componentStack : undefined; + } catch { + return undefined; + } +} diff --git a/packages/react-native/tests/exceptionsManagerHelperTests.spec.ts b/packages/react-native/tests/exceptionsManagerHelperTests.spec.ts new file mode 100644 index 00000000..63dea6d0 --- /dev/null +++ b/packages/react-native/tests/exceptionsManagerHelperTests.spec.ts @@ -0,0 +1,58 @@ +import type { ExceptionsManager } from '../src/common/exceptionsManagerHelper'; + +const EXCEPTIONS_MANAGER = 'react-native/Libraries/Core/ExceptionsManager'; + +function resolveWith(factory: () => unknown) { + let resolved: { manager?: ExceptionsManager; module?: unknown } = {}; + try { + jest.isolateModules(() => { + jest.doMock(EXCEPTIONS_MANAGER, factory); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { exceptionsManager } = require('../src/common/exceptionsManagerHelper'); + resolved = { manager: exceptionsManager() }; + try { + resolved.module = require(EXCEPTIONS_MANAGER); + } catch { + // The missing module case. + } + }); + } finally { + jest.dontMock(EXCEPTIONS_MANAGER); + } + return resolved; +} + +describe('exceptionsManager', () => { + it('Should return the module object when React Native exports it with module.exports', () => { + const { manager, module } = resolveWith(() => ({ handleException: jest.fn() })); + + expect(manager).toBe(module); + }); + + it('Should return the default export when React Native exports it as an ES module', () => { + const { manager, module } = resolveWith(() => ({ __esModule: true, default: { handleException: jest.fn() } })); + + expect(manager).toBe((module as { default: unknown }).default); + }); + + it('Should return nothing when the module has no handleException', () => { + expect(resolveWith(() => ({ default: {} })).manager).toBeUndefined(); + }); + + it('Should return nothing when the module cannot be loaded', () => { + const { manager } = resolveWith(() => { + throw new Error('Cannot find module'); + }); + + expect(manager).toBeUndefined(); + }); + + it("Should return React Native's own ExceptionsManager", () => { + /* eslint-disable @typescript-eslint/no-var-requires */ + const { exceptionsManager } = require('../src/common/exceptionsManagerHelper'); + const reactNativeExceptionsManager = require(EXCEPTIONS_MANAGER).default; + /* eslint-enable @typescript-eslint/no-var-requires */ + + expect(exceptionsManager()).toBe(reactNativeExceptionsManager); + }); +}); diff --git a/packages/react-native/tests/renderErrorHandlerTests.spec.ts b/packages/react-native/tests/renderErrorHandlerTests.spec.ts new file mode 100644 index 00000000..9a1ff72a --- /dev/null +++ b/packages/react-native/tests/renderErrorHandlerTests.spec.ts @@ -0,0 +1,257 @@ +import { BacktraceReport } from '@backtrace/sdk-core'; +import type { BacktraceClient } from '../src/BacktraceClient'; +import type { ExceptionsManager } from '../src/common/exceptionsManagerHelper'; + +jest.mock('promise/setimmediate/rejection-tracking', () => ({ + enable: jest.fn(), +})); + +jest.mock('../src/crashReporter/CrashReporter', () => ({ + CrashReporter: { markFatalError: jest.fn() }, +})); + +const mockExceptionsManager: { current?: ExceptionsManager } = {}; + +jest.mock('../src/common/exceptionsManagerHelper', () => ({ + exceptionsManager: () => mockExceptionsManager.current, +})); + +import { CrashReporter } from '../src/crashReporter/CrashReporter'; +import { UnhandledExceptionHandler } from '../src/handlers/UnhandledExceptionHandler'; + +const markFatalErrorMock = CrashReporter.markFatalError as jest.Mock; + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +function renderError(message: string, componentStack = '\n in Bomb\n in App') { + return Object.assign(new Error(message), { componentStack, isComponentError: true }); +} + +describe('UnhandledExceptionHandler render errors', () => { + let sendMock: jest.Mock; + let client: BacktraceClient; + let handler: UnhandledExceptionHandler; + let originalHandleException: jest.Mock; + let manager: ExceptionsManager; + let globalHandler: (error: Error, fatal?: boolean) => void; + let reactNativeGlobalHandler: jest.Mock; + let originalErrorUtils: unknown; + let originalDev: PropertyDescriptor | undefined; + + beforeEach(() => { + originalDev = Object.getOwnPropertyDescriptor(globalThis, '__DEV__'); + Object.defineProperty(globalThis, '__DEV__', { configurable: true, value: false }); + markFatalErrorMock.mockClear(); + sendMock = jest.fn().mockResolvedValue(undefined); + client = { send: sendMock } as unknown as BacktraceClient; + handler = new UnhandledExceptionHandler(); + + originalHandleException = jest.fn(); + manager = { handleException: originalHandleException }; + mockExceptionsManager.current = manager; + + reactNativeGlobalHandler = jest.fn((error: Error, fatal?: boolean) => manager.handleException(error, !!fatal)); + originalErrorUtils = (global as unknown as { ErrorUtils?: unknown }).ErrorUtils; + (global as unknown as { ErrorUtils: unknown }).ErrorUtils = { + getGlobalHandler: () => reactNativeGlobalHandler, + setGlobalHandler: (fn: typeof globalHandler) => { + globalHandler = fn; + }, + }; + }); + + afterEach(() => { + handler.dispose(); + (global as unknown as { ErrorUtils: unknown }).ErrorUtils = originalErrorUtils; + if (originalDev) { + Object.defineProperty(globalThis, '__DEV__', originalDev); + } else { + Reflect.deleteProperty(globalThis, '__DEV__'); + } + }); + + it('Should report an uncaught render error once with its component stack and forward it after the report settles', async () => { + let resolveSend!: () => void; + sendMock.mockReturnValue( + new Promise((resolve) => { + resolveSend = resolve; + }), + ); + handler.captureManagedErrors(client); + const error = renderError('render boom'); + + manager.handleException(error, true); + + expect(sendMock).toHaveBeenCalledTimes(1); + const report = sendMock.mock.calls[0][0] as BacktraceReport; + expect(report).toBeInstanceOf(BacktraceReport); + expect(report.data).toBe(error); + expect(report.attributes).toMatchObject({ 'error.type': 'Unhandled exception', fatal: true }); + expect(report.stackTrace['component-stack']).toEqual({ stack: '\n in Bomb\n in App', message: '' }); + expect(originalHandleException).not.toHaveBeenCalled(); + + resolveSend(); + await flush(); + + expect(originalHandleException).toHaveBeenCalledTimes(1); + expect(originalHandleException).toHaveBeenCalledWith(error, true); + expect(markFatalErrorMock).toHaveBeenCalledTimes(1); + expect(markFatalErrorMock.mock.invocationCallOrder[0]).toBeLessThan( + originalHandleException.mock.invocationCallOrder[0], + ); + }); + + it('Should report an uncaught render error and forward it at once without marking it in a debug build', () => { + Object.defineProperty(globalThis, '__DEV__', { configurable: true, value: true }); + sendMock.mockReturnValue(new Promise(() => undefined)); + handler.captureManagedErrors(client); + const error = renderError('render boom'); + + manager.handleException(error, true); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(markFatalErrorMock).not.toHaveBeenCalled(); + expect(originalHandleException).toHaveBeenCalledWith(error, true); + }); + + it('Should forward a render error caught by an error boundary without reporting it', () => { + handler.captureManagedErrors(client); + const error = renderError('caught'); + + manager.handleException(error, false); + + expect(sendMock).not.toHaveBeenCalled(); + expect(originalHandleException).toHaveBeenCalledWith(error, false); + }); + + it('Should leave fatal errors that are not render errors to the global error handler', () => { + handler.captureManagedErrors(client); + const error = new Error('plain fatal'); + + manager.handleException(error, true); + + expect(sendMock).not.toHaveBeenCalled(); + expect(markFatalErrorMock).not.toHaveBeenCalled(); + expect(originalHandleException).toHaveBeenCalledWith(error, true); + }); + + it('Should report a React 18 render error once when the renderer rethrows it to the global error handler', async () => { + handler.captureManagedErrors(client); + const error = renderError('react 18 boom'); + + manager.handleException(error, false); + globalHandler(error, true); + await flush(); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect((sendMock.mock.calls[0][0] as BacktraceReport).stackTrace['component-stack']).toBeDefined(); + expect(originalHandleException.mock.calls).toEqual([ + [error, false], + [error, true], + ]); + }); + + it('Should mark a reported fatal error again when another handler delays React Native', async () => { + reactNativeGlobalHandler.mockImplementation((error: Error, fatal?: boolean) => { + setTimeout(() => manager.handleException(error, !!fatal), 10); + }); + handler.captureManagedErrors(client); + const error = new Error('boom'); + + globalHandler(error, true); + await flush(); + expect(markFatalErrorMock).toHaveBeenCalledTimes(1); + expect(originalHandleException).not.toHaveBeenCalled(); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(markFatalErrorMock).toHaveBeenCalledTimes(2); + expect(originalHandleException).toHaveBeenCalledWith(error, true); + expect(markFatalErrorMock.mock.invocationCallOrder[1]).toBeLessThan( + originalHandleException.mock.invocationCallOrder[0], + ); + }); + + it('Should forward a reported fatal error without reporting it again after the client is initialized again', async () => { + handler.captureManagedErrors(client); + const error = new Error('boom'); + globalHandler(error, true); + await flush(); + + handler.dispose(); + handler = new UnhandledExceptionHandler(); + handler.captureManagedErrors(client); + sendMock.mockClear(); + markFatalErrorMock.mockClear(); + reactNativeGlobalHandler.mockClear(); + + globalHandler(error, true); + + expect(sendMock).not.toHaveBeenCalled(); + expect(markFatalErrorMock).toHaveBeenCalled(); + expect(reactNativeGlobalHandler).toHaveBeenCalledWith(error, true); + expect(originalHandleException).toHaveBeenLastCalledWith(error, true); + }); + + it('Should not report a pending render error again after the client is disposed and initialized again', async () => { + handler.captureManagedErrors(client); + const error = renderError('react 18 boom'); + globalHandler(error, true); + + handler.dispose(); + handler = new UnhandledExceptionHandler(); + handler.captureManagedErrors(client); + await flush(); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(originalHandleException).toHaveBeenCalledWith(error, true); + }); + + it('Should restore the original handler on dispose', () => { + handler.captureManagedErrors(client); + + handler.dispose(); + + expect(manager.handleException).toBe(originalHandleException); + }); + + it('Should keep a wrapper installed on top of ours and only forward through it after dispose', () => { + handler.captureManagedErrors(client); + const ours = manager.handleException; + const outer = jest.fn((error: unknown, isFatal: boolean) => ours.call(manager, error, isFatal)); + manager.handleException = outer; + + handler.dispose(); + const error = renderError('after dispose'); + manager.handleException(error, true); + + expect(manager.handleException).toBe(outer); + expect(sendMock).not.toHaveBeenCalled(); + expect(originalHandleException).toHaveBeenCalledWith(error, true); + }); + + it('Should keep capturing global errors when the ExceptionsManager is unavailable', () => { + mockExceptionsManager.current = undefined; + + handler.captureManagedErrors(client); + globalHandler(new Error('boom'), false); + + expect(sendMock).toHaveBeenCalledTimes(1); + }); + + it('Should keep capturing global errors when the ExceptionsManager cannot be patched', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + Object.freeze(manager); + + handler.captureManagedErrors(client); + globalHandler(new Error('boom'), false); + + expect(manager.handleException).toBe(originalHandleException); + expect(sendMock).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Backtrace: failed to capture render errors outside an error boundary'), + ); + warn.mockRestore(); + }); +}); diff --git a/packages/react-native/tests/renderErrorRendererTests.spec.ts b/packages/react-native/tests/renderErrorRendererTests.spec.ts new file mode 100644 index 00000000..17715f23 --- /dev/null +++ b/packages/react-native/tests/renderErrorRendererTests.spec.ts @@ -0,0 +1,149 @@ +import type { BacktraceReport } from '@backtrace/sdk-core'; +import type * as ReactTypes from 'react'; +import type { BacktraceClient } from '../src/BacktraceClient'; + +type NativeException = { isFatal: boolean; message: string }; + +const mockNativeExceptions: NativeException[] = []; + +jest.mock('react-native/Libraries/Core/NativeExceptionsManager', () => ({ + __esModule: true, + default: { + reportException: (data: NativeException) => mockNativeExceptions.push(data), + }, +})); + +jest.mock('promise/setimmediate/rejection-tracking', () => ({ + enable: jest.fn(), +})); + +jest.mock('../src/crashReporter/CrashReporter', () => ({ + CrashReporter: { markFatalError: jest.fn() }, +})); + +(global as unknown as { __DEV__: boolean }).__DEV__ = false; +Object.assign((global as unknown as { nativeFabricUIManager: object }).nativeFabricUIManager, { + createChildSet: () => [], + appendChildToSet: (set: unknown[], child: unknown) => set.push(child), + completeRoot: () => undefined, +}); + +/* eslint-disable @typescript-eslint/no-var-requires */ +const React = require('react') as typeof ReactTypes; +const { renderElement } = require('react-native/Libraries/ReactNative/RendererImplementation'); +const ExceptionsManager = require('react-native/Libraries/Core/ExceptionsManager').default; +const ReactFiberErrorDialog = require('react-native/Libraries/Core/ReactFiberErrorDialog').default; +const { CrashReporter } = require('../src/crashReporter/CrashReporter'); +const { UnhandledExceptionHandler } = require('../src/handlers/UnhandledExceptionHandler'); +/* eslint-enable @typescript-eslint/no-var-requires */ + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function Bomb(): ReactTypes.ReactElement { + throw new Error('render boom'); +} + +class Boundary extends React.Component<{ children: ReactTypes.ReactNode }, { failed: boolean }> { + state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + render() { + return this.state.failed ? null : this.props.children; + } +} + +describe('Render errors through the React Native renderer', () => { + let sendMock: jest.Mock; + let handler: InstanceType; + let originalErrorUtils: unknown; + let consoleError: jest.SpyInstance; + let globalHandler: (error: unknown, fatal?: boolean) => void; + let rootTag = 1; + + beforeEach(() => { + mockNativeExceptions.length = 0; + (CrashReporter.markFatalError as jest.Mock).mockClear(); + sendMock = jest.fn().mockResolvedValue(undefined); + consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + originalErrorUtils = (global as unknown as { ErrorUtils?: unknown }).ErrorUtils; + (global as unknown as { ErrorUtils: unknown }).ErrorUtils = { + getGlobalHandler: () => (error: unknown, fatal: boolean) => ExceptionsManager.handleException(error, fatal), + setGlobalHandler: (fn: typeof globalHandler) => { + globalHandler = fn; + }, + }; + + handler = new UnhandledExceptionHandler(); + handler.captureManagedErrors({ send: sendMock } as unknown as BacktraceClient); + }); + + afterEach(() => { + handler.dispose(); + consoleError.mockRestore(); + delete (global as unknown as { RN$handleException?: unknown }).RN$handleException; + (global as unknown as { ErrorUtils: unknown }).ErrorUtils = originalErrorUtils; + }); + + async function render(element: ReactTypes.ReactElement, useFabric: boolean) { + rootTag += 10; + renderElement({ element, rootTag, useFabric, useConcurrentRoot: useFabric }); + await wait(50); + } + + it.each([ + ['the old architecture renderer', false, false], + ['the New Architecture renderer', true, false], + ['the New Architecture renderer with the bridgeless error handler', true, true], + ])( + 'Should report an uncaught render error once with its component stack before React Native crashes the app on %s', + async (_name, useFabric, bridgeless) => { + const bridgelessHandler = jest.fn(() => false); + if (bridgeless) { + (global as unknown as { RN$handleException: unknown }).RN$handleException = bridgelessHandler; + } + + await render(React.createElement(Bomb), useFabric); + + expect(sendMock).toHaveBeenCalledTimes(1); + const report = sendMock.mock.calls[0][0] as BacktraceReport; + expect((report.data as Error).message).toBe('render boom'); + expect(report.attributes).toMatchObject({ 'error.type': 'Unhandled exception', fatal: true }); + expect(report.stackTrace['component-stack']).toMatchObject({ stack: expect.stringContaining('Bomb') }); + + expect(mockNativeExceptions).toHaveLength(1); + expect(mockNativeExceptions[0].isFatal).toBe(true); + expect(CrashReporter.markFatalError).toHaveBeenCalledTimes(1); + expect(bridgelessHandler).toHaveBeenCalledTimes(bridgeless ? 1 : 0); + }, + ); + + it.each([ + ['the old architecture renderer', false], + ['the New Architecture renderer', true], + ])('Should leave a render error caught by an error boundary unreported on %s', async (_name, useFabric) => { + await render(React.createElement(Boundary, null, React.createElement(Bomb)), useFabric); + + expect(sendMock).not.toHaveBeenCalled(); + expect(CrashReporter.markFatalError).not.toHaveBeenCalled(); + expect(mockNativeExceptions).toHaveLength(1); + expect(mockNativeExceptions[0].isFatal).toBe(false); + }); + + it('Should report a React 18 render error once when the renderer rethrows it to the global error handler', async () => { + const error = new Error('react 18 boom'); + + ReactFiberErrorDialog.showErrorDialog({ componentStack: '\n in Bomb', error, errorBoundary: null }); + globalHandler(error, true); + await wait(50); + + expect(sendMock).toHaveBeenCalledTimes(1); + const report = sendMock.mock.calls[0][0] as BacktraceReport; + expect(report.data).toBe(error); + expect(report.stackTrace['component-stack']).toMatchObject({ stack: '\n in Bomb' }); + expect(mockNativeExceptions.map((exception) => exception.isFatal)).toEqual([false, true]); + }); +});