From 8cf7a88849d6a7bd1c10662c4e3b99ba391dd469 Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Tue, 6 Oct 2026 20:12:53 +0200 Subject: [PATCH 1/5] react-native: android fatal js errors report twice and hang 5 s --- .github/scripts/android-js-fatal.sh | 97 +++++++++++++ .github/scripts/ci-crash-driver.js | 8 ++ .github/workflows/android.yml | 5 +- .../sdk/reactNative/android/app/build.gradle | 3 + .../java/com/reactnative/MainApplication.java | 2 +- ...idBackgroundUnhandledExceptionHandler.java | 33 ++++- ...handledExceptionHandlerFatalErrorTest.java | 101 ++++++++++++++ .../src/handlers/UnhandledExceptionHandler.ts | 125 +++++++++++++---- .../AndroidUnhandledExceptionHandler.ts | 15 +- ...roidUnhandledExceptionHandlerTests.spec.ts | 59 +++++++- .../tests/errorHandlerSafetyTests.spec.ts | 54 +++++++- .../unhandledExceptionHandlerTests.spec.ts | 131 +++++++++++++++++- 12 files changed, 592 insertions(+), 41 deletions(-) create mode 100644 .github/scripts/android-js-fatal.sh create mode 100644 packages/react-native/android/src/test/java/backtraceio/library/BacktraceAndroidBackgroundUnhandledExceptionHandlerFatalErrorTest.java diff --git a/.github/scripts/android-js-fatal.sh b/.github/scripts/android-js-fatal.sh new file mode 100644 index 00000000..896a6685 --- /dev/null +++ b/.github/scripts/android-js-fatal.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Asserts a fatal JS error produces one report, no duplicate from the rethrown JavascriptException, and no 5 s wait. +set -euo pipefail + +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" +EXIT_BOUND_S=8 + +wait_for_log() { + local pattern="$1" deadline="$2" + for _ in $(seq 1 "$deadline"); do + if adb logcat -d | grep -qE "$pattern"; then + return 0 + fi + sleep 1 + done + + echo "::error::timed out after ${deadline}s waiting for: $pattern" + adb logcat -d | tail -50 + return 1 +} + +log_time() { + adb logcat -d -v epoch | grep -E "$1" | head -1 | awk '{print $1}' +} + +trap 'adb logcat -d > /tmp/js-fatal-logcat.txt 2>/dev/null || true' EXIT + +adb wait-for-device +adb uninstall "$PACKAGE" >/dev/null 2>&1 || true +adb install -r "$APK" +adb shell pm clear "$PACKAGE" >/dev/null +adb logcat -c + +adb shell am start -n "$ACTIVITY" >/dev/null +wait_for_log "BT_CI_DRIVER_ARMED" 120 + +adb shell am start -n "$ACTIVITY" -a android.intent.action.VIEW -d "'$TRIGGER_URL'" >/dev/null +echo "fatal JS error fired: $MARKER" + +wait_for_log "FATAL EXCEPTION" 30 +for _ in $(seq 1 30); do + if ! adb shell pidof "$PACKAGE" >/dev/null 2>&1; then + break + fi + sleep 1 +done +adb logcat -d > /tmp/js-fatal-logcat.txt + +if adb shell pidof "$PACKAGE" >/dev/null 2>&1; then + echo "::error::the app is still running 30 s after the fatal JS error" + exit 1 +fi + +FIRED_AT="$(log_time "BT_CI_DRIVER firing: backtrace-example://ci-js-fatal")" +DIED_AT="$(log_time "FATAL EXCEPTION")" +ELAPSED="$(python3 -c "print(round(float('$DIED_AT') - float('$FIRED_AT'), 1))")" +echo "process exit ${ELAPSED}s after the fatal JS error" +if python3 -c "import sys; sys.exit(0 if float('$ELAPSED') <= $EXIT_BOUND_S else 1)"; then :; else + echo "::error::the process exited ${ELAPSED}s after the fatal, expected at most ${EXIT_BOUND_S}s" + exit 1 +fi + +if ! grep -q "Skipping the JavascriptException of a fatal error already reported from JavaScript" /tmp/js-fatal-logcat.txt; then + echo "::error::the Java handler did not skip the rethrown JavascriptException" + grep -iE "backtrace|BT_CI|FATAL" /tmp/js-fatal-logcat.txt | tail -30 + exit 1 +fi +if grep -q "Timed out waiting for the unhandled exception report" /tmp/js-fatal-logcat.txt; then + echo "::error::the Java handler waited for a report that could never arrive" + exit 1 +fi + +# The example's placeholder URL rejects the upload. +RECORDS="$(adb shell run-as "$PACKAGE" find files/backtrace -maxdepth 1 -name '*-record.json' 2>/dev/null | tr -d '\r' || true)" +COUNT="$(printf '%s\n' "$RECORDS" | grep -c . || true)" +echo "database records: $COUNT" +if [ "$COUNT" != "1" ]; then + echo "::error::expected exactly one report record, found $COUNT" + printf '%s\n' "$RECORDS" + exit 1 +fi +adb exec-out run-as "$PACKAGE" cat "$RECORDS" > /tmp/js-fatal-record.json + +if ! grep -q "BT_CI_JS_FATAL $MARKER" /tmp/js-fatal-record.json; then + echo "::error::the stored report is not the fatal JS error" + head -c 2000 /tmp/js-fatal-record.json + exit 1 +fi +if grep -q "JavascriptException" /tmp/js-fatal-record.json; then + echo "::error::the stored report is the rethrown JavascriptException, not the JS error" + exit 1 +fi +echo "one report with the JS error, no JavascriptException duplicate, exit after ${ELAPSED}s" diff --git a/.github/scripts/ci-crash-driver.js b/.github/scripts/ci-crash-driver.js index 99b3d8b1..e7052b04 100644 --- a/.github/scripts/ci-crash-driver.js +++ b/.github/scripts/ci-crash-driver.js @@ -2,6 +2,14 @@ import { Linking } from 'react-native'; Linking.addEventListener('url', ({ url }) => { + const fatal = /^backtrace-example:\/\/ci-js-fatal\?marker=([A-Za-z0-9-]+)$/.exec(url ?? ''); + if (fatal) { + console.log(`BT_CI_DRIVER firing: ${url}`); + setTimeout(() => { + throw new Error(`BT_CI_JS_FATAL ${fatal[1]}`); + }, 0); + return; + } const match = /^backtrace-example:\/\/ci-native-crash\?marker=([A-Za-z0-9-]+)$/.exec(url ?? ''); if (!match) { return; diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 4d04691a..be5add26 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 on the emulator, single APK then Play-style split install + - name: Capture a native crash, single APK then Play-style split install, and a fatal JS error on the emulator uses: reactivecircus/android-emulator-runner@v2 env: BUNDLETOOL_JAR: ${{ runner.temp }}/bundletool.jar @@ -127,6 +127,7 @@ jobs: script: | 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 - name: Upload crash evidence if: failure() @@ -136,5 +137,7 @@ jobs: path: | /tmp/native-crash-*.dmp /tmp/logcat-*.txt + /tmp/js-fatal-logcat.txt + /tmp/js-fatal-record.json if-no-files-found: warn retention-days: 7 diff --git a/examples/sdk/reactNative/android/app/build.gradle b/examples/sdk/reactNative/android/app/build.gradle index bbb3d27a..5d05f8e0 100644 --- a/examples/sdk/reactNative/android/app/build.gradle +++ b/examples/sdk/reactNative/android/app/build.gradle @@ -110,6 +110,7 @@ android { buildTypes { debug { signingConfig signingConfigs.debug + buildConfigField "boolean", "USE_DEVELOPER_SUPPORT", "true" } release { // Caution! In production, you need to generate your own keystore file. @@ -117,6 +118,8 @@ android { signingConfig signingConfigs.debug // CI-only, so run-as can read the crashpad database. debuggable project.hasProperty("btCiDebuggable") + // BuildConfig.DEBUG is true in the debuggable CI build. + buildConfigField "boolean", "USE_DEVELOPER_SUPPORT", "false" minifyEnabled enableProguardInReleaseBuilds || project.hasProperty("btCiMinify") proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" } diff --git a/examples/sdk/reactNative/android/app/src/main/java/com/reactnative/MainApplication.java b/examples/sdk/reactNative/android/app/src/main/java/com/reactnative/MainApplication.java index c053d405..a0b08560 100644 --- a/examples/sdk/reactNative/android/app/src/main/java/com/reactnative/MainApplication.java +++ b/examples/sdk/reactNative/android/app/src/main/java/com/reactnative/MainApplication.java @@ -32,7 +32,7 @@ public String getJSMainModuleName() { @Override public boolean getUseDeveloperSupport() { - return BuildConfig.DEBUG; + return BuildConfig.USE_DEVELOPER_SUPPORT; } @Override diff --git a/packages/react-native/android/src/main/java/backtrace/library/BacktraceAndroidBackgroundUnhandledExceptionHandler.java b/packages/react-native/android/src/main/java/backtrace/library/BacktraceAndroidBackgroundUnhandledExceptionHandler.java index 12e4f835..c3db6076 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/BacktraceAndroidBackgroundUnhandledExceptionHandler.java +++ b/packages/react-native/android/src/main/java/backtrace/library/BacktraceAndroidBackgroundUnhandledExceptionHandler.java @@ -9,6 +9,7 @@ import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.common.JavascriptException; import com.facebook.react.module.annotations.ReactModule; import java.io.PrintWriter; @@ -37,6 +38,10 @@ public class BacktraceAndroidBackgroundUnhandledExceptionHandler extends ReactCo private static final long REPORT_PROCESSED_TIMEOUT_MS = 5000; + private static final long FATAL_ERROR_MARK_TIMEOUT_MS = 5000; + + private volatile long _fatalErrorMarkedAtNanos = 0; + public static final String NAME = "BacktraceAndroidBackgroundUnhandledExceptionHandler"; public BacktraceAndroidBackgroundUnhandledExceptionHandler(ReactApplicationContext reactContext) { @@ -66,7 +71,9 @@ public void start(Callback callback) { @Override public synchronized void uncaughtException(final Thread thread, final Throwable throwable) { try { - if (throwable instanceof Exception) { + if (isReportedFatalError(throwable)) { + Log.d(LOG_TAG, "Skipping the JavascriptException of a fatal error already reported from JavaScript."); + } else if (throwable instanceof Exception) { report(throwable); } } catch (RuntimeException ex) { @@ -79,6 +86,25 @@ public synchronized void uncaughtException(final Thread thread, final Throwable } } + // The interop layer rejects a void synchronous method. + @ReactMethod(isBlockingSynchronousMethod = true) + public boolean markFatalError() { + _fatalErrorMarkedAtNanos = System.nanoTime(); + return true; + } + + private boolean isReportedFatalError(Throwable throwable) { + if (!(throwable instanceof JavascriptException)) { + return false; + } + long markedAtNanos = _fatalErrorMarkedAtNanos; + if (markedAtNanos == 0) { + return false; + } + _fatalErrorMarkedAtNanos = 0; + return System.nanoTime() - markedAtNanos <= TimeUnit.MILLISECONDS.toNanos(FATAL_ERROR_MARK_TIMEOUT_MS); + } + private void report(Throwable throwable) { Callback callback = _callback.getAndSet(null); if (callback == null) { @@ -102,10 +128,11 @@ private void waitForReportProcessing(CountDownLatch reportProcessed) { } // not synchronized: the crashing thread holds this monitor while it waits - @ReactMethod - public void reportProcessed() { + @ReactMethod(isBlockingSynchronousMethod = true) + public boolean reportProcessed() { Log.d(LOG_TAG, "Unhandled exception report processed by the JavaScript side."); _reportProcessed.countDown(); + return true; } private static String stackTraceToString(StackTraceElement[] stackTrace) { diff --git a/packages/react-native/android/src/test/java/backtraceio/library/BacktraceAndroidBackgroundUnhandledExceptionHandlerFatalErrorTest.java b/packages/react-native/android/src/test/java/backtraceio/library/BacktraceAndroidBackgroundUnhandledExceptionHandlerFatalErrorTest.java new file mode 100644 index 00000000..a15e6109 --- /dev/null +++ b/packages/react-native/android/src/test/java/backtraceio/library/BacktraceAndroidBackgroundUnhandledExceptionHandlerFatalErrorTest.java @@ -0,0 +1,101 @@ +package backtraceio.library; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import com.facebook.react.bridge.Callback; +import com.facebook.react.common.JavascriptException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class BacktraceAndroidBackgroundUnhandledExceptionHandlerFatalErrorTest { + private static final long WAIT_BOUND_MS = 1000; + + private Thread.UncaughtExceptionHandler previousDefaultHandler; + private final List rootHandlerThrowables = new ArrayList<>(); + private final List callbackInvocations = new ArrayList<>(); + private BacktraceAndroidBackgroundUnhandledExceptionHandler handler; + + @Before + public void installHandler() { + previousDefaultHandler = Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> rootHandlerThrowables.add(throwable)); + handler = new BacktraceAndroidBackgroundUnhandledExceptionHandler(null); + Callback callback = args -> { + callbackInvocations.add(args); + handler.reportProcessed(); + }; + handler.start(callback); + } + + @After + public void restoreDefaultHandler() { + handler.stop(); + Thread.setDefaultUncaughtExceptionHandler(previousDefaultHandler); + } + + @Test + public void markedJavascriptExceptionSkipsTheReportAndTheWait() { + JavascriptException exception = new JavascriptException("Error: boom\n at app (index.js:1:1)"); + handler.markFatalError(); + + long elapsedMs = timed(() -> handler.uncaughtException(Thread.currentThread(), exception)); + + assertEquals(0, callbackInvocations.size()); + assertEquals(1, rootHandlerThrowables.size()); + assertSame(exception, rootHandlerThrowables.get(0)); + assertTrue("forwarded after " + elapsedMs + " ms", elapsedMs < WAIT_BOUND_MS); + } + + @Test + public void unmarkedJavascriptExceptionIsReported() { + JavascriptException exception = new JavascriptException("Error: boom"); + + handler.uncaughtException(Thread.currentThread(), exception); + + assertEquals(1, callbackInvocations.size()); + assertEquals(JavascriptException.class.getName(), callbackInvocations.get(0)[0]); + assertEquals(1, rootHandlerThrowables.size()); + assertSame(exception, rootHandlerThrowables.get(0)); + } + + @Test + public void markIsConsumedByTheFirstJavascriptException() { + handler.markFatalError(); + handler.uncaughtException(Thread.currentThread(), new JavascriptException("first")); + + handler.uncaughtException(Thread.currentThread(), new JavascriptException("second")); + + assertEquals(1, callbackInvocations.size()); + assertEquals(2, rootHandlerThrowables.size()); + } + + @Test + public void markDoesNotApplyToOtherExceptions() { + RuntimeException exception = new IllegalStateException("background failure"); + handler.markFatalError(); + + handler.uncaughtException(Thread.currentThread(), exception); + + assertEquals(1, callbackInvocations.size()); + assertEquals(IllegalStateException.class.getName(), callbackInvocations.get(0)[0]); + assertSame(exception, rootHandlerThrowables.get(0)); + } + + @Test + public void synchronousMethodsReturnAValue() { + assertTrue(handler.markFatalError()); + assertTrue(handler.reportProcessed()); + } + + private static long timed(Runnable runnable) { + long start = System.nanoTime(); + runnable.run(); + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + } +} diff --git a/packages/react-native/src/handlers/UnhandledExceptionHandler.ts b/packages/react-native/src/handlers/UnhandledExceptionHandler.ts index add2ae1b..7f5f13e0 100644 --- a/packages/react-native/src/handlers/UnhandledExceptionHandler.ts +++ b/packages/react-native/src/handlers/UnhandledExceptionHandler.ts @@ -1,5 +1,6 @@ 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 { hermes } from '../common/hermesHelper'; import { CrashReporter } from '../crashReporter/CrashReporter'; @@ -16,6 +17,8 @@ const rejectionTracking = require('promise/setimmediate/rejection-tracking') as type GlobalErrorHandler = ReturnType; +const FATAL_REPORT_TIMEOUT_MS = 5000; + export class UnhandledExceptionHandler implements ExceptionHandler { protected enabled = true; @@ -26,6 +29,8 @@ export class UnhandledExceptionHandler implements ExceptionHandler { private _reportingManagedError = false; private _reportingRejection = false; private _warning = false; + private _pendingFatal?: Promise; + private _escalatedError?: unknown; public captureManagedErrors(client: BacktraceClient): void { if (!this.enabled || this._installedHandler) { @@ -36,36 +41,51 @@ export class UnhandledExceptionHandler implements ExceptionHandler { this._managedClient = client; const installedHandler: GlobalErrorHandler = (error: Error, fatal?: boolean) => { + if (this._escalatedError !== undefined && error === this._escalatedError) { + this._escalatedError = undefined; + throw error; + } + const managedClient = this._managedClient; - if (this.enabled && managedClient && !this._reportingManagedError) { - this._reportingManagedError = true; - - try { - // send() can throw before returning a promise. Observe rejected - // submissions without delaying the application's fatal handler. - void Promise.resolve( - managedClient.send(error, { - 'error.type': 'Unhandled exception', - fatal, - }), - ).catch(() => undefined); - - // iOS: RCTFatal turns a fatal into a native crash the reporter would double-report. - // Preserve native reporting when synchronous JS report construction fails. - if (fatal) { - CrashReporter.markFatalError(); - } - } catch (err) { - this.warnReportingFailure('failed to report an unhandled error', err); - } finally { - this._reportingManagedError = false; - } + 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; } - // Disposed and re-entered wrappers still forward. - // Exceptions from the application's original handler must keep their original behavior. - previousHandler(error, fatal); + 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; + } + + if (this.isInBackground()) { + // JS timers are paused in the background. + this.markFatalErrorSafely(); + 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))); }; try { @@ -142,6 +162,59 @@ export class UnhandledExceptionHandler implements ExceptionHandler { // could disable a newer tracker installed by another SDK. } + protected markFatalError(): void { + CrashReporter.markFatalError(); + } + + private queueFatalForwarding(forward: () => Promise | void): void { + const pending = (this._pendingFatal ?? Promise.resolve()).then(forward).then(() => { + if (this._pendingFatal === pending) { + this._pendingFatal = undefined; + } + }); + this._pendingFatal = pending; + } + + private forwardFatal(previousHandler: GlobalErrorHandler, error: Error, fatal: boolean): void { + this.markFatalErrorSafely(); + try { + previousHandler(error, fatal); + } catch (err) { + // React Native's native fallback ends the process only for a throw out of the global handler. + this._escalatedError = err; + setTimeout(() => { + throw err; + }, 0); + } + } + + private markFatalErrorSafely(): void { + try { + this.markFatalError(); + } catch (err) { + this.warnReportingFailure('failed to mark the fatal error for the native handler', err); + } + } + + private isInBackground(): boolean { + try { + return AppState.currentState === 'background'; + } catch { + return false; + } + } + + private settle(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, timeoutMs); + const done = () => { + clearTimeout(timer); + resolve(); + }; + promise.then(done, done); + }); + } + private reportRejection(id: number, rejection: unknown = 'Unknown'): void { const client = this._rejectionClient; if (!this.enabled || !client || this._reportingRejection) { diff --git a/packages/react-native/src/handlers/android/AndroidUnhandledExceptionHandler.ts b/packages/react-native/src/handlers/android/AndroidUnhandledExceptionHandler.ts index f2beeb4c..f7c773cc 100644 --- a/packages/react-native/src/handlers/android/AndroidUnhandledExceptionHandler.ts +++ b/packages/react-native/src/handlers/android/AndroidUnhandledExceptionHandler.ts @@ -5,6 +5,9 @@ import { DebuggerHelper } from '../../common/DebuggerHelper'; import { AndroidStackTraceConverter } from '../../converters/AndroidStackTraceConverter'; import { UnhandledExceptionHandler } from '../UnhandledExceptionHandler'; import { AndroidUnhandledException } from './AndroidUnhandledException'; + +const JAVASCRIPT_EXCEPTION = 'com.facebook.react.common.JavascriptException'; + export class AndroidUnhandledExceptionHandler extends UnhandledExceptionHandler { private readonly _unhandledExceptionHandler = NativeModules.BacktraceAndroidBackgroundUnhandledExceptionHandler; private readonly _androidStackTraceConverter = new AndroidStackTraceConverter(); @@ -27,7 +30,12 @@ export class AndroidUnhandledExceptionHandler extends UnhandledExceptionHandler [], ); report.addStackTrace('main', this._androidStackTraceConverter.convert(stackTrace)); - await client.send(report); + const submission = client.send(report); + if (classifier === JAVASCRIPT_EXCEPTION) { + // The upload's response needs the native modules thread, the one that crashed. + this._unhandledExceptionHandler.reportProcessed?.(); + } + await submission; } catch { // nothing to recover: the process is dying } finally { @@ -42,4 +50,9 @@ export class AndroidUnhandledExceptionHandler extends UnhandledExceptionHandler this._unhandledExceptionHandler.stop(); } } + + // React Native rethrows the fatal as a JavascriptException the Java handler must not report again. + protected markFatalError(): void { + this._unhandledExceptionHandler?.markFatalError?.(); + } } diff --git a/packages/react-native/tests/androidUnhandledExceptionHandlerTests.spec.ts b/packages/react-native/tests/androidUnhandledExceptionHandlerTests.spec.ts index 4dea7597..66b4cb4c 100644 --- a/packages/react-native/tests/androidUnhandledExceptionHandlerTests.spec.ts +++ b/packages/react-native/tests/androidUnhandledExceptionHandlerTests.spec.ts @@ -24,11 +24,13 @@ jest.mock('../src/common/DebuggerHelper', () => ({ })); import { NativeModules } from 'react-native'; +import { CrashReporter } from '../src/crashReporter/CrashReporter'; const nativeHandlerMock = { start: jest.fn(), stop: jest.fn(), reportProcessed: jest.fn(), + markFatalError: jest.fn(), }; NativeModules.BacktraceAndroidBackgroundUnhandledExceptionHandler = nativeHandlerMock; @@ -37,18 +39,26 @@ NativeModules.BacktraceAndroidBackgroundUnhandledExceptionHandler = nativeHandle const { AndroidUnhandledExceptionHandler } = require('../src/handlers/android/AndroidUnhandledExceptionHandler'); type NativeExceptionCallback = (classifier: string, message: string, stackTrace: string) => Promise; +type GlobalHandler = (error: Error, fatal?: boolean) => void; + +const flush = () => new Promise((resolve) => setImmediate(resolve)); describe('AndroidUnhandledExceptionHandler', () => { let originalErrorUtils: unknown; + let previousGlobalHandler: jest.Mock; + let registeredHandler: GlobalHandler; beforeEach(() => { jest.clearAllMocks(); mockIsNativeBridgeEnabled.mockReturnValue(true); + previousGlobalHandler = jest.fn(); originalErrorUtils = (global as unknown as { ErrorUtils?: unknown }).ErrorUtils; (global as unknown as { ErrorUtils: unknown }).ErrorUtils = { - getGlobalHandler: () => jest.fn(), - setGlobalHandler: jest.fn(), + getGlobalHandler: () => previousGlobalHandler, + setGlobalHandler: (fn: GlobalHandler) => { + registeredHandler = fn; + }, }; }); @@ -81,6 +91,16 @@ describe('AndroidUnhandledExceptionHandler', () => { expect(nativeHandlerMock.reportProcessed).toHaveBeenCalledTimes(1); }); + it('Should signal reportProcessed before the send settles for a JavascriptException', async () => { + const sendMock = jest.fn().mockReturnValue(new Promise(() => undefined)); + const callback = captureNativeCallback({ send: sendMock } as unknown as BacktraceClient); + + void callback('com.facebook.react.common.JavascriptException', 'Error: boom', 'a.b(C.java:1)'); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(nativeHandlerMock.reportProcessed).toHaveBeenCalledTimes(1); + }); + it('Should signal reportProcessed when the send fails', async () => { const sendMock = jest.fn().mockRejectedValue(new Error('offline')); const callback = captureNativeCallback({ send: sendMock } as unknown as BacktraceClient); @@ -108,6 +128,41 @@ describe('AndroidUnhandledExceptionHandler', () => { expect(nativeHandlerMock.start).not.toHaveBeenCalled(); }); + it('Should mark the fatal error on the Java handler, not the crash reporter, before forwarding it', async () => { + const sendMock = jest.fn().mockResolvedValue(undefined); + new AndroidUnhandledExceptionHandler().captureManagedErrors({ send: sendMock } as unknown as BacktraceClient); + const error = new Error('boom'); + + registeredHandler(error, true); + await flush(); + + expect(nativeHandlerMock.markFatalError).toHaveBeenCalledTimes(1); + expect(CrashReporter.markFatalError).not.toHaveBeenCalled(); + expect(previousGlobalHandler).toHaveBeenCalledWith(error, true); + expect(nativeHandlerMock.markFatalError.mock.invocationCallOrder[0]).toBeLessThan( + previousGlobalHandler.mock.invocationCallOrder[0], + ); + }); + + it('Should forward a fatal error when the Java handler module has no markFatalError', async () => { + const { markFatalError, ...olderModule } = nativeHandlerMock; + NativeModules.BacktraceAndroidBackgroundUnhandledExceptionHandler = olderModule; + try { + new AndroidUnhandledExceptionHandler().captureManagedErrors({ + send: jest.fn().mockResolvedValue(undefined), + } as unknown as BacktraceClient); + const error = new Error('boom'); + + registeredHandler(error, true); + await flush(); + + expect(markFatalError).not.toHaveBeenCalled(); + expect(previousGlobalHandler).toHaveBeenCalledWith(error, true); + } finally { + NativeModules.BacktraceAndroidBackgroundUnhandledExceptionHandler = nativeHandlerMock; + } + }); + it('Should stop the native handler on dispose', () => { const handler = new AndroidUnhandledExceptionHandler(); handler.captureManagedErrors({ send: jest.fn() } as unknown as BacktraceClient); diff --git a/packages/react-native/tests/errorHandlerSafetyTests.spec.ts b/packages/react-native/tests/errorHandlerSafetyTests.spec.ts index f5bef38c..9e4aaad3 100644 --- a/packages/react-native/tests/errorHandlerSafetyTests.spec.ts +++ b/packages/react-native/tests/errorHandlerSafetyTests.spec.ts @@ -25,6 +25,7 @@ const rejectionTracking = require('promise/setimmediate/rejection-tracking') as }; type Handler = (error: Error, fatal?: boolean) => void; +const flush = () => new Promise((resolve) => setImmediate(resolve)); type RejectionOptions = { allRejections: boolean; onUnhandled: (id: number, rejection?: unknown) => void; @@ -91,28 +92,71 @@ describe('UnhandledExceptionHandler safety', () => { expect(warn).toHaveBeenCalledWith('Backtrace: failed to report an unhandled error (Error)'); }); - it('observes asynchronous failures without delaying delegation or fatal marking', async () => { + it('delegates a fatal error once the failed submission settles and still marks it', async () => { send.mockRejectedValue(new Error('transport failure')); handler.captureManagedErrors(client); const error = new Error('application failure'); current(error, true); + expect(previous).not.toHaveBeenCalled(); + await flush(); expect(previous).toHaveBeenCalledTimes(1); expect(previous).toHaveBeenCalledWith(error, true); expect(CrashReporter.markFatalError).toHaveBeenCalledTimes(1); - // A rejected submission must be handled in the same turn, - // even if the process remains alive after the application's handler returns. - await new Promise((resolve) => setImmediate(resolve)); + expect(warn).not.toHaveBeenCalled(); }); - it('delegates even when native fatal marking fails', () => { + it('delegates even when native fatal marking fails', async () => { (CrashReporter.markFatalError as jest.Mock).mockImplementation(() => { throw new Error('native marker failed'); }); handler.captureManagedErrors(client); const error = new Error('application failure'); expect(() => current(error, true)).not.toThrow(); + await flush(); + expect(previous).toHaveBeenCalledTimes(1); + expect(previous).toHaveBeenCalledWith(error, true); + expect(warn).toHaveBeenCalledWith( + 'Backtrace: failed to mark the fatal error for the native handler (Error)', + ); + }); + + it('rethrows a failing previous fatal handler from a timer without reporting it again', async () => { + const failure = new Error('previous handler failure'); + previous.mockImplementation((_error: Error, fatal?: boolean) => { + if (fatal) { + throw failure; + } + }); + const realSetTimeout = setTimeout; + let escalation: (() => void) | undefined; + jest.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: () => void, ms?: number) => { + if (ms === 0) { + escalation = fn; + return 0 as unknown as NodeJS.Timeout; + } + return realSetTimeout(fn, ms); + }) as typeof setTimeout); + handler.captureManagedErrors(client); + current(new Error('application failure'), true); + await flush(); + expect(previous).toHaveBeenCalledTimes(1); + expect(escalation).toBeDefined(); + expect(() => escalation?.()).toThrow(failure); + expect(() => current(failure, true)).toThrow(failure); + expect(send).toHaveBeenCalledTimes(1); + expect(previous).toHaveBeenCalledTimes(1); + }); + + it('forwards a fatal error synchronously when synchronous reporting fails', () => { + send.mockImplementation(() => { + throw new Error('report construction failure'); + }); + handler.captureManagedErrors(client); + const error = new Error('application failure'); + current(error, true); expect(previous).toHaveBeenCalledTimes(1); expect(previous).toHaveBeenCalledWith(error, true); + expect(CrashReporter.markFatalError).not.toHaveBeenCalled(); }); it('does not swallow exceptions from the previous global handler', () => { diff --git a/packages/react-native/tests/unhandledExceptionHandlerTests.spec.ts b/packages/react-native/tests/unhandledExceptionHandlerTests.spec.ts index ae01897d..0c539896 100644 --- a/packages/react-native/tests/unhandledExceptionHandlerTests.spec.ts +++ b/packages/react-native/tests/unhandledExceptionHandlerTests.spec.ts @@ -21,11 +21,19 @@ jest.mock('../src/common/hermesHelper', () => ({ // eslint-disable-next-line @typescript-eslint/no-var-requires const rejectionTracking = require('promise/setimmediate/rejection-tracking'); +import { AppState } from 'react-native'; 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)); +const flushMicrotasks = async () => { + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } +}; + describe('UnhandledExceptionHandler labeling', () => { let sendMock: jest.Mock; let client: BacktraceClient; @@ -103,16 +111,135 @@ describe('UnhandledExceptionHandler managed errors', () => { (global as unknown as { ErrorUtils: unknown }).ErrorUtils = originalErrorUtils; }); - it('Should mark a fatal unhandled error so the native reporter skips the duplicate, then chain the previous handler', () => { + it('Should forward a fatal unhandled error only after the report settles, marking it for the native reporter first', async () => { + let resolveSend!: () => void; + sendMock.mockReturnValue( + new Promise((resolve) => { + resolveSend = resolve; + }), + ); const error = new Error('boom'); + registeredHandler(error, true); expect(sendMock).toHaveBeenCalledWith(error, { 'error.type': 'Unhandled exception', fatal: true }); + expect(markFatalErrorMock).not.toHaveBeenCalled(); + expect(previousGlobalHandler).not.toHaveBeenCalled(); + + resolveSend(); + await flush(); + + expect(markFatalErrorMock).toHaveBeenCalledTimes(1); + expect(previousGlobalHandler).toHaveBeenCalledWith(error, true); + expect(markFatalErrorMock.mock.invocationCallOrder[0]).toBeLessThan( + previousGlobalHandler.mock.invocationCallOrder[0], + ); + }); + + it('Should forward a fatal unhandled error when the report fails', async () => { + sendMock.mockRejectedValue(new Error('offline')); + const error = new Error('boom'); + + registeredHandler(error, true); + await flush(); + expect(markFatalErrorMock).toHaveBeenCalledTimes(1); + expect(previousGlobalHandler).toHaveBeenCalledTimes(1); expect(previousGlobalHandler).toHaveBeenCalledWith(error, true); }); - it('Should NOT mark a non-fatal unhandled error', () => { + it('Should forward a fatal unhandled error after 5 seconds when the report never settles', async () => { + jest.useFakeTimers(); + try { + sendMock.mockReturnValue(new Promise(() => undefined)); + const error = new Error('boom'); + + registeredHandler(error, true); + await flushMicrotasks(); + expect(previousGlobalHandler).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(4999); + await flushMicrotasks(); + expect(previousGlobalHandler).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + await flushMicrotasks(); + expect(markFatalErrorMock).toHaveBeenCalledTimes(1); + expect(previousGlobalHandler).toHaveBeenCalledWith(error, true); + } finally { + jest.useRealTimers(); + } + }); + + it('Should forward a fatal error at once and mark it when the app is in the background', () => { + const appState = AppState as unknown as { currentState: unknown }; + const original = appState.currentState; + appState.currentState = 'background'; + try { + sendMock.mockReturnValue(new Promise(() => undefined)); + const error = new Error('boom'); + + registeredHandler(error, true); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(markFatalErrorMock).toHaveBeenCalledTimes(1); + expect(previousGlobalHandler).toHaveBeenCalledWith(error, true); + } finally { + appState.currentState = original; + } + }); + + it('Should report a second fatal error while the first is pending and forward both in order', async () => { + let resolveSend!: () => void; + sendMock.mockReturnValue( + new Promise((resolve) => { + resolveSend = resolve; + }), + ); + const first = new Error('first'); + const second = new Error('second'); + + registeredHandler(first, true); + registeredHandler(second, true); + + expect(sendMock).toHaveBeenCalledTimes(2); + expect(previousGlobalHandler).not.toHaveBeenCalled(); + + resolveSend(); + await flush(); + + expect(previousGlobalHandler.mock.calls).toEqual([ + [first, true], + [second, true], + ]); + }); + + it('Should report fatal errors again once the pending one is forwarded', async () => { + sendMock.mockResolvedValue(undefined); + + registeredHandler(new Error('first'), true); + await flush(); + registeredHandler(new Error('later'), true); + await flush(); + + expect(sendMock).toHaveBeenCalledTimes(2); + expect(previousGlobalHandler).toHaveBeenCalledTimes(2); + }); + + it('Should keep reporting non-fatal errors while a fatal error is pending', async () => { + sendMock.mockReturnValue(new Promise(() => undefined)); + const fatal = new Error('fatal'); + const nonFatal = new Error('warning'); + + registeredHandler(fatal, true); + registeredHandler(nonFatal, false); + + expect(sendMock).toHaveBeenCalledTimes(2); + expect(previousGlobalHandler).toHaveBeenCalledTimes(1); + expect(previousGlobalHandler).toHaveBeenCalledWith(nonFatal, false); + }); + + it('Should forward a non-fatal unhandled error synchronously without marking it', () => { const error = new Error('boom'); registeredHandler(error, false); From 2e0497b0c4e5955852de63018af869c2f68b1117 Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Wed, 7 Oct 2026 12:25:51 +0200 Subject: [PATCH 2/5] react-native: end the fatal error wait in the background, skip it in debug builds --- .../src/handlers/UnhandledExceptionHandler.ts | 42 +++++++++--- ...roidUnhandledExceptionHandlerTests.spec.ts | 36 ++++++++++ .../unhandledExceptionHandlerTests.spec.ts | 65 +++++++++++++++++++ 3 files changed, 135 insertions(+), 8 deletions(-) diff --git a/packages/react-native/src/handlers/UnhandledExceptionHandler.ts b/packages/react-native/src/handlers/UnhandledExceptionHandler.ts index 7f5f13e0..7ab0d836 100644 --- a/packages/react-native/src/handlers/UnhandledExceptionHandler.ts +++ b/packages/react-native/src/handlers/UnhandledExceptionHandler.ts @@ -76,9 +76,15 @@ export class UnhandledExceptionHandler implements ExceptionHandler { 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(); + this.markFatalErrorSafely(error); previousHandler(error, fatal); return; } @@ -162,9 +168,7 @@ export class UnhandledExceptionHandler implements ExceptionHandler { // could disable a newer tracker installed by another SDK. } - protected markFatalError(): void { - CrashReporter.markFatalError(); - } + protected markFatalError: (message: string) => void = () => CrashReporter.markFatalError(); private queueFatalForwarding(forward: () => Promise | void): void { const pending = (this._pendingFatal ?? Promise.resolve()).then(forward).then(() => { @@ -176,7 +180,7 @@ export class UnhandledExceptionHandler implements ExceptionHandler { } private forwardFatal(previousHandler: GlobalErrorHandler, error: Error, fatal: boolean): void { - this.markFatalErrorSafely(); + this.markFatalErrorSafely(error); try { previousHandler(error, fatal); } catch (err) { @@ -188,14 +192,22 @@ export class UnhandledExceptionHandler implements ExceptionHandler { } } - private markFatalErrorSafely(): void { + private markFatalErrorSafely(error: unknown): void { try { - this.markFatalError(); + this.markFatalError(this.fatalErrorMessage(error)); } catch (err) { this.warnReportingFailure('failed to mark the fatal error for the native handler', err); } } + // Mirrors the message React Native builds for the rethrown error. + private fatalErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message ? String(error.message) : ''; + } + return error === undefined ? '' : String(error); + } + private isInBackground(): boolean { try { return AppState.currentState === 'background'; @@ -206,15 +218,29 @@ export class UnhandledExceptionHandler implements ExceptionHandler { private settle(promise: Promise, timeoutMs: number): Promise { return new Promise((resolve) => { - const timer = setTimeout(resolve, timeoutMs); const done = () => { clearTimeout(timer); + subscription?.remove(); resolve(); }; + const timer = setTimeout(done, timeoutMs); + const subscription = this.onBackground(done); promise.then(done, done); }); } + private onBackground(callback: () => void): { remove(): void } | undefined { + try { + return AppState.addEventListener('change', (state) => { + if (state === 'background') { + callback(); + } + }); + } catch { + return undefined; + } + } + private reportRejection(id: number, rejection: unknown = 'Unknown'): void { const client = this._rejectionClient; if (!this.enabled || !client || this._reportingRejection) { diff --git a/packages/react-native/tests/androidUnhandledExceptionHandlerTests.spec.ts b/packages/react-native/tests/androidUnhandledExceptionHandlerTests.spec.ts index 66b4cb4c..b78ad9cf 100644 --- a/packages/react-native/tests/androidUnhandledExceptionHandlerTests.spec.ts +++ b/packages/react-native/tests/androidUnhandledExceptionHandlerTests.spec.ts @@ -47,8 +47,11 @@ describe('AndroidUnhandledExceptionHandler', () => { let originalErrorUtils: unknown; let previousGlobalHandler: jest.Mock; let registeredHandler: GlobalHandler; + let originalDev: PropertyDescriptor | undefined; beforeEach(() => { + originalDev = Object.getOwnPropertyDescriptor(globalThis, '__DEV__'); + Object.defineProperty(globalThis, '__DEV__', { configurable: true, value: false }); jest.clearAllMocks(); mockIsNativeBridgeEnabled.mockReturnValue(true); previousGlobalHandler = jest.fn(); @@ -64,6 +67,11 @@ describe('AndroidUnhandledExceptionHandler', () => { afterEach(() => { (global as unknown as { ErrorUtils: unknown }).ErrorUtils = originalErrorUtils; + if (originalDev) { + Object.defineProperty(globalThis, '__DEV__', originalDev); + } else { + Reflect.deleteProperty(globalThis, '__DEV__'); + } }); function captureNativeCallback(client: BacktraceClient): NativeExceptionCallback { @@ -137,6 +145,7 @@ describe('AndroidUnhandledExceptionHandler', () => { await flush(); expect(nativeHandlerMock.markFatalError).toHaveBeenCalledTimes(1); + expect(nativeHandlerMock.markFatalError).toHaveBeenCalledWith('boom'); expect(CrashReporter.markFatalError).not.toHaveBeenCalled(); expect(previousGlobalHandler).toHaveBeenCalledWith(error, true); expect(nativeHandlerMock.markFatalError.mock.invocationCallOrder[0]).toBeLessThan( @@ -144,6 +153,33 @@ describe('AndroidUnhandledExceptionHandler', () => { ); }); + it('Should pass the message of a thrown non-Error value to the Java handler', async () => { + new AndroidUnhandledExceptionHandler().captureManagedErrors({ + send: jest.fn().mockResolvedValue(undefined), + } as unknown as BacktraceClient); + + registeredHandler('plain failure' as unknown as Error, true); + await flush(); + + expect(nativeHandlerMock.markFatalError).toHaveBeenCalledWith('plain failure'); + }); + + it.each([ + ['undefined', undefined, ''], + ['an Error without a message', new Error(), ''], + ['null', null, 'null'], + ['the number 0', 0, '0'], + ])("Should pass React Native's message for %s to the Java handler", async (_label, thrown, expected) => { + new AndroidUnhandledExceptionHandler().captureManagedErrors({ + send: jest.fn().mockResolvedValue(undefined), + } as unknown as BacktraceClient); + + registeredHandler(thrown as unknown as Error, true); + await flush(); + + expect(nativeHandlerMock.markFatalError).toHaveBeenCalledWith(expected); + }); + it('Should forward a fatal error when the Java handler module has no markFatalError', async () => { const { markFatalError, ...olderModule } = nativeHandlerMock; NativeModules.BacktraceAndroidBackgroundUnhandledExceptionHandler = olderModule; diff --git a/packages/react-native/tests/unhandledExceptionHandlerTests.spec.ts b/packages/react-native/tests/unhandledExceptionHandlerTests.spec.ts index 0c539896..e6e962f3 100644 --- a/packages/react-native/tests/unhandledExceptionHandlerTests.spec.ts +++ b/packages/react-native/tests/unhandledExceptionHandlerTests.spec.ts @@ -88,8 +88,11 @@ describe('UnhandledExceptionHandler managed errors', () => { let registeredHandler: (error: Error, fatal?: boolean) => void; let previousGlobalHandler: 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(); client = { send: sendMock } as unknown as BacktraceClient; @@ -109,6 +112,12 @@ describe('UnhandledExceptionHandler managed errors', () => { afterEach(() => { (global as unknown as { ErrorUtils: unknown }).ErrorUtils = originalErrorUtils; + if (originalDev) { + Object.defineProperty(globalThis, '__DEV__', originalDev); + } else { + Reflect.deleteProperty(globalThis, '__DEV__'); + } + jest.restoreAllMocks(); }); it('Should forward a fatal unhandled error only after the report settles, marking it for the native reporter first', async () => { @@ -189,6 +198,62 @@ describe('UnhandledExceptionHandler managed errors', () => { } }); + it('Should forward a pending fatal error once the app goes to the background', async () => { + let onChange!: (state: string) => void; + jest.spyOn(AppState, 'addEventListener').mockImplementation((( + _type: string, + listener: (state: string) => void, + ) => { + onChange = listener; + return { remove: jest.fn() }; + }) as unknown as typeof AppState.addEventListener); + sendMock.mockReturnValue(new Promise(() => undefined)); + const error = new Error('boom'); + + registeredHandler(error, true); + await flush(); + expect(previousGlobalHandler).not.toHaveBeenCalled(); + + onChange('inactive'); + await flush(); + expect(previousGlobalHandler).not.toHaveBeenCalled(); + + onChange('background'); + await flush(); + + expect(markFatalErrorMock).toHaveBeenCalledTimes(1); + expect(previousGlobalHandler).toHaveBeenCalledWith(error, true); + expect(markFatalErrorMock.mock.invocationCallOrder[0]).toBeLessThan( + previousGlobalHandler.mock.invocationCallOrder[0], + ); + }); + + it('Should stop listening for the background once the report settles', async () => { + const remove = jest.fn(); + jest.spyOn(AppState, 'addEventListener').mockReturnValue({ + remove, + } as unknown as ReturnType); + sendMock.mockResolvedValue(undefined); + + registeredHandler(new Error('boom'), true); + await flush(); + + expect(previousGlobalHandler).toHaveBeenCalledTimes(1); + expect(remove).toHaveBeenCalled(); + }); + + it('Should forward a fatal error at once without marking it in a debug build', () => { + Object.defineProperty(globalThis, '__DEV__', { configurable: true, value: true }); + sendMock.mockReturnValue(new Promise(() => undefined)); + const error = new Error('boom'); + + registeredHandler(error, true); + + expect(sendMock).toHaveBeenCalledWith(error, { 'error.type': 'Unhandled exception', fatal: true }); + expect(markFatalErrorMock).not.toHaveBeenCalled(); + expect(previousGlobalHandler).toHaveBeenCalledWith(error, true); + }); + it('Should report a second fatal error while the first is pending and forward both in order', async () => { let resolveSend!: () => void; sendMock.mockReturnValue( From b8b825c23cca5708d78faa674781e5deabd7974d Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Wed, 7 Oct 2026 12:26:06 +0200 Subject: [PATCH 3/5] react-native: skip the rethrown javascript exception only when its message matches --- ...idBackgroundUnhandledExceptionHandler.java | 72 ++++++++++++++++--- ...handledExceptionHandlerFatalErrorTest.java | 63 +++++++++++++--- .../AndroidUnhandledExceptionHandler.ts | 6 +- 3 files changed, 122 insertions(+), 19 deletions(-) diff --git a/packages/react-native/android/src/main/java/backtrace/library/BacktraceAndroidBackgroundUnhandledExceptionHandler.java b/packages/react-native/android/src/main/java/backtrace/library/BacktraceAndroidBackgroundUnhandledExceptionHandler.java index ee74aa4c..cee798ff 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/BacktraceAndroidBackgroundUnhandledExceptionHandler.java +++ b/packages/react-native/android/src/main/java/backtrace/library/BacktraceAndroidBackgroundUnhandledExceptionHandler.java @@ -14,6 +14,9 @@ import java.io.PrintWriter; import java.io.StringWriter; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; @@ -40,7 +43,9 @@ public class BacktraceAndroidBackgroundUnhandledExceptionHandler extends ReactCo private static final long FATAL_ERROR_MARK_TIMEOUT_MS = 5000; - private volatile long _fatalErrorMarkedAtNanos = 0; + private static final int MAX_FATAL_ERROR_MARKS = 8; + + private final List _fatalErrorMarks = new ArrayList<>(); public static final String NAME = "BacktraceAndroidBackgroundUnhandledExceptionHandler"; @@ -88,8 +93,14 @@ public synchronized void uncaughtException(final Thread thread, final Throwable // The interop layer rejects a void synchronous method. @ReactMethod(isBlockingSynchronousMethod = true) - public boolean markFatalError() { - _fatalErrorMarkedAtNanos = System.nanoTime(); + public boolean markFatalError(String message) { + synchronized (_fatalErrorMarks) { + removeExpiredMarks(); + if (_fatalErrorMarks.size() == MAX_FATAL_ERROR_MARKS) { + _fatalErrorMarks.remove(0); + } + _fatalErrorMarks.add(new FatalErrorMark(message, System.nanoTime())); + } return true; } @@ -97,12 +108,35 @@ private boolean isReportedFatalError(Throwable throwable) { if (!(throwable instanceof JavascriptException)) { return false; } - long markedAtNanos = _fatalErrorMarkedAtNanos; - if (markedAtNanos == 0) { - return false; + String errorMessage = errorMessageOf((JavascriptException) throwable); + synchronized (_fatalErrorMarks) { + removeExpiredMarks(); + for (Iterator marks = _fatalErrorMarks.iterator(); marks.hasNext(); ) { + if (marks.next().matches(errorMessage)) { + marks.remove(); + return true; + } + } } - _fatalErrorMarkedAtNanos = 0; - return System.nanoTime() - markedAtNanos <= TimeUnit.MILLISECONDS.toNanos(FATAL_ERROR_MARK_TIMEOUT_MS); + return false; + } + + private void removeExpiredMarks() { + for (Iterator marks = _fatalErrorMarks.iterator(); marks.hasNext(); ) { + if (!marks.next().isRecent()) { + marks.remove(); + } + } + } + + // React Native appends ", stack:" and the JS frames to the error message. + private static String errorMessageOf(JavascriptException exception) { + String text = exception.getMessage(); + if (text == null) { + return ""; + } + int stackStart = text.indexOf(", stack:"); + return stackStart >= 0 ? text.substring(0, stackStart) : text; } private void report(Throwable throwable) { @@ -135,6 +169,28 @@ public boolean reportProcessed() { return true; } + private static final class FatalErrorMark { + private final String message; + private final long markedAtNanos; + + FatalErrorMark(String message, long markedAtNanos) { + this.message = message == null ? "" : message; + this.markedAtNanos = markedAtNanos; + } + + boolean matches(String errorMessage) { + if (message.isEmpty()) { + String trimmed = errorMessage.trim(); + return trimmed.isEmpty() || trimmed.endsWith(":"); + } + return errorMessage.contains(message); + } + + boolean isRecent() { + return System.nanoTime() - markedAtNanos <= TimeUnit.MILLISECONDS.toNanos(FATAL_ERROR_MARK_TIMEOUT_MS); + } + } + private static String stackTraceToString(StackTraceElement[] stackTrace) { StringWriter sw = new StringWriter(); printStackTrace(stackTrace, new PrintWriter(sw)); diff --git a/packages/react-native/android/src/test/java/backtraceio/library/BacktraceAndroidBackgroundUnhandledExceptionHandlerFatalErrorTest.java b/packages/react-native/android/src/test/java/backtraceio/library/BacktraceAndroidBackgroundUnhandledExceptionHandlerFatalErrorTest.java index a15e6109..90a986ad 100644 --- a/packages/react-native/android/src/test/java/backtraceio/library/BacktraceAndroidBackgroundUnhandledExceptionHandlerFatalErrorTest.java +++ b/packages/react-native/android/src/test/java/backtraceio/library/BacktraceAndroidBackgroundUnhandledExceptionHandlerFatalErrorTest.java @@ -41,8 +41,8 @@ public void restoreDefaultHandler() { @Test public void markedJavascriptExceptionSkipsTheReportAndTheWait() { - JavascriptException exception = new JavascriptException("Error: boom\n at app (index.js:1:1)"); - handler.markFatalError(); + JavascriptException exception = new JavascriptException("Error: boom, stack:\nanonymous@1:26921"); + handler.markFatalError("boom"); long elapsedMs = timed(() -> handler.uncaughtException(Thread.currentThread(), exception)); @@ -52,6 +52,53 @@ public void markedJavascriptExceptionSkipsTheReportAndTheWait() { assertTrue("forwarded after " + elapsedMs + " ms", elapsedMs < WAIT_BOUND_MS); } + @Test + public void javascriptExceptionWithAnotherMessageIsReported() { + JavascriptException exception = new JavascriptException("Error: render failed, stack:\nanonymous@1:1"); + handler.markFatalError("boom"); + + handler.uncaughtException(Thread.currentThread(), exception); + + assertEquals(1, callbackInvocations.size()); + assertEquals(JavascriptException.class.getName(), callbackInvocations.get(0)[0]); + assertSame(exception, rootHandlerThrowables.get(0)); + } + + @Test + public void markWithoutMessageSkipsOnlyAJavascriptExceptionWithoutMessage() { + handler.markFatalError(""); + handler.uncaughtException(Thread.currentThread(), new JavascriptException("Error: render failed, stack:\nanonymous@1:1")); + assertEquals(1, callbackInvocations.size()); + + handler.uncaughtException(Thread.currentThread(), new JavascriptException("Error: , stack:\nanonymous@1:1")); + handler.markFatalError(""); + handler.uncaughtException(Thread.currentThread(), new JavascriptException(", stack:\nhandleException@1:1")); + + assertEquals(1, callbackInvocations.size()); + assertEquals(3, rootHandlerThrowables.size()); + } + + @Test + public void markMatchesTheErrorMessageOnlyNotTheStack() { + handler.markFatalError("anonymous"); + + handler.uncaughtException(Thread.currentThread(), new JavascriptException("Error: render failed, stack:\nanonymous@1:1")); + + assertEquals(1, callbackInvocations.size()); + } + + @Test + public void twoMarksSetBeforeEitherRethrowSkipBoth() { + handler.markFatalError("first"); + handler.markFatalError("second"); + + handler.uncaughtException(Thread.currentThread(), new JavascriptException("Error: first, stack:\nanonymous@1:1")); + handler.uncaughtException(Thread.currentThread(), new JavascriptException("TypeError: second, stack:\nanonymous@1:1")); + + assertEquals(0, callbackInvocations.size()); + assertEquals(2, rootHandlerThrowables.size()); + } + @Test public void unmarkedJavascriptExceptionIsReported() { JavascriptException exception = new JavascriptException("Error: boom"); @@ -66,10 +113,10 @@ public void unmarkedJavascriptExceptionIsReported() { @Test public void markIsConsumedByTheFirstJavascriptException() { - handler.markFatalError(); - handler.uncaughtException(Thread.currentThread(), new JavascriptException("first")); + handler.markFatalError("boom"); + handler.uncaughtException(Thread.currentThread(), new JavascriptException("Error: boom, stack:")); - handler.uncaughtException(Thread.currentThread(), new JavascriptException("second")); + handler.uncaughtException(Thread.currentThread(), new JavascriptException("Error: boom, stack:")); assertEquals(1, callbackInvocations.size()); assertEquals(2, rootHandlerThrowables.size()); @@ -77,8 +124,8 @@ public void markIsConsumedByTheFirstJavascriptException() { @Test public void markDoesNotApplyToOtherExceptions() { - RuntimeException exception = new IllegalStateException("background failure"); - handler.markFatalError(); + RuntimeException exception = new IllegalStateException("boom"); + handler.markFatalError("boom"); handler.uncaughtException(Thread.currentThread(), exception); @@ -89,7 +136,7 @@ public void markDoesNotApplyToOtherExceptions() { @Test public void synchronousMethodsReturnAValue() { - assertTrue(handler.markFatalError()); + assertTrue(handler.markFatalError("boom")); assertTrue(handler.reportProcessed()); } diff --git a/packages/react-native/src/handlers/android/AndroidUnhandledExceptionHandler.ts b/packages/react-native/src/handlers/android/AndroidUnhandledExceptionHandler.ts index f7c773cc..74029894 100644 --- a/packages/react-native/src/handlers/android/AndroidUnhandledExceptionHandler.ts +++ b/packages/react-native/src/handlers/android/AndroidUnhandledExceptionHandler.ts @@ -52,7 +52,7 @@ export class AndroidUnhandledExceptionHandler extends UnhandledExceptionHandler } // React Native rethrows the fatal as a JavascriptException the Java handler must not report again. - protected markFatalError(): void { - this._unhandledExceptionHandler?.markFatalError?.(); - } + protected markFatalError = (message: string) => { + this._unhandledExceptionHandler?.markFatalError?.(message); + }; } From 396142ac1e8ab72462bebb1ef10420f165a22571 Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Wed, 7 Oct 2026 12:26:14 +0200 Subject: [PATCH 4/5] ci: check the android fatal js error by stored reports and process exit --- .github/scripts/android-js-fatal.sh | 66 ++++++++++++++--------------- .github/scripts/ci-crash-driver.js | 8 ++++ .github/workflows/android.yml | 1 - 3 files changed, 41 insertions(+), 34 deletions(-) diff --git a/.github/scripts/android-js-fatal.sh b/.github/scripts/android-js-fatal.sh index 896a6685..e46b306b 100644 --- a/.github/scripts/android-js-fatal.sh +++ b/.github/scripts/android-js-fatal.sh @@ -7,7 +7,9 @@ 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" -EXIT_BOUND_S=8 +LOGCAT_OUT="/tmp/js-fatal-logcat.txt" +FORWARD_BOUND_S=7 +EXIT_BOUND_S=2 wait_for_log() { local pattern="$1" deadline="$2" @@ -23,11 +25,15 @@ wait_for_log() { return 1 } -log_time() { - adb logcat -d -v epoch | grep -E "$1" | head -1 | awk '{print $1}' +seconds_between() { + python3 -c "print(round(float('$2') - float('$1'), 2))" } -trap 'adb logcat -d > /tmp/js-fatal-logcat.txt 2>/dev/null || true' EXIT +at_most() { + python3 -c "import sys; sys.exit(0 if float('$1') <= float('$2') else 1)" +} + +trap 'adb logcat -d -v epoch > "$LOGCAT_OUT" 2>/dev/null || true' EXIT adb wait-for-device adb uninstall "$PACKAGE" >/dev/null 2>&1 || true @@ -37,61 +43,55 @@ adb logcat -c adb shell am start -n "$ACTIVITY" >/dev/null 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" +echo "fatal JS error fired: $MARKER, pid $PID" -wait_for_log "FATAL EXCEPTION" 30 for _ in $(seq 1 30); do if ! adb shell pidof "$PACKAGE" >/dev/null 2>&1; then break fi sleep 1 done -adb logcat -d > /tmp/js-fatal-logcat.txt - if adb shell pidof "$PACKAGE" >/dev/null 2>&1; then echo "::error::the app is still running 30 s after the fatal JS error" exit 1 fi +sleep 1 +adb logcat -d -v epoch > "$LOGCAT_OUT" -FIRED_AT="$(log_time "BT_CI_DRIVER firing: backtrace-example://ci-js-fatal")" -DIED_AT="$(log_time "FATAL EXCEPTION")" -ELAPSED="$(python3 -c "print(round(float('$DIED_AT') - float('$FIRED_AT'), 1))")" -echo "process exit ${ELAPSED}s after the fatal JS error" -if python3 -c "import sys; sys.exit(0 if float('$ELAPSED') <= $EXIT_BOUND_S else 1)"; then :; else - echo "::error::the process exited ${ELAPSED}s after the fatal, expected at most ${EXIT_BOUND_S}s" +FIRED_AT="$(awk '/BT_CI_DRIVER firing: backtrace-example:\/\/ci-js-fatal/ { 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 + echo "::error::missing log lines: fired '$FIRED_AT', rethrown '$RETHROWN_AT', died '$DIED_AT'" + grep -E "BT_CI|AndroidRuntime|has died" "$LOGCAT_OUT" | tail -30 exit 1 fi -if ! grep -q "Skipping the JavascriptException of a fatal error already reported from JavaScript" /tmp/js-fatal-logcat.txt; then - echo "::error::the Java handler did not skip the rethrown JavascriptException" - grep -iE "backtrace|BT_CI|FATAL" /tmp/js-fatal-logcat.txt | tail -30 +FORWARD_S="$(seconds_between "$FIRED_AT" "$RETHROWN_AT")" +EXIT_S="$(seconds_between "$RETHROWN_AT" "$DIED_AT")" +echo "React Native rethrew ${FORWARD_S}s after the fatal JS error, the process died ${EXIT_S}s later" +if ! at_most "$FORWARD_S" "$FORWARD_BOUND_S"; then + echo "::error::the JS handler held the fatal error ${FORWARD_S}s, expected at most ${FORWARD_BOUND_S}s" exit 1 fi -if grep -q "Timed out waiting for the unhandled exception report" /tmp/js-fatal-logcat.txt; then - echo "::error::the Java handler waited for a report that could never arrive" +if ! at_most "$EXIT_S" "$EXIT_BOUND_S"; then + echo "::error::the process died ${EXIT_S}s after the rethrow, expected at most ${EXIT_BOUND_S}s" exit 1 fi -# The example's placeholder URL rejects the upload. -RECORDS="$(adb shell run-as "$PACKAGE" find files/backtrace -maxdepth 1 -name '*-record.json' 2>/dev/null | tr -d '\r' || true)" +RECORDS="$(awk -v since="$FIRED_AT" '$1 >= since && /BT_CI_RECORD/ { sub(/.*BT_CI_RECORD /, ""); print }' "$LOGCAT_OUT")" COUNT="$(printf '%s\n' "$RECORDS" | grep -c . || true)" -echo "database records: $COUNT" +echo "reports stored: $COUNT" +printf '%s\n' "$RECORDS" if [ "$COUNT" != "1" ]; then - echo "::error::expected exactly one report record, found $COUNT" - printf '%s\n' "$RECORDS" + echo "::error::expected exactly one stored report, found $COUNT" exit 1 fi -adb exec-out run-as "$PACKAGE" cat "$RECORDS" > /tmp/js-fatal-record.json - -if ! grep -q "BT_CI_JS_FATAL $MARKER" /tmp/js-fatal-record.json; then +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" - head -c 2000 /tmp/js-fatal-record.json - exit 1 -fi -if grep -q "JavascriptException" /tmp/js-fatal-record.json; then - echo "::error::the stored report is the rethrown JavascriptException, not the JS error" exit 1 fi -echo "one report with the JS error, no JavascriptException duplicate, exit after ${ELAPSED}s" +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 e7052b04..40a67949 100644 --- a/.github/scripts/ci-crash-driver.js +++ b/.github/scripts/ci-crash-driver.js @@ -20,4 +20,12 @@ Linking.addEventListener('url', ({ url }) => { client.crash(); }); +BacktraceClient.instance?.database?.on('added', (record) => { + if (record.type === 'report') { + console.log( + `BT_CI_RECORD ${record.data.classifiers?.[0]} ${JSON.stringify(record.data.attributes?.['error.message'] ?? '')}`, + ); + } +}); + console.log('BT_CI_DRIVER_ARMED'); diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index be5add26..40ea9353 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -138,6 +138,5 @@ jobs: /tmp/native-crash-*.dmp /tmp/logcat-*.txt /tmp/js-fatal-logcat.txt - /tmp/js-fatal-record.json if-no-files-found: warn retention-days: 7 From b034e3a474d3f4a6bad67db61e8ce54d31c37bb0 Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Wed, 7 Oct 2026 12:56:59 +0200 Subject: [PATCH 5/5] react-native: report react render errors that no error boundary catches --- .github/scripts/android-js-fatal.sh | 26 +- .github/scripts/ci-crash-driver.js | 31 ++- .github/workflows/android.yml | 4 +- .../src/common/exceptionsManagerHelper.ts | 19 ++ .../src/handlers/UnhandledExceptionHandler.ts | 191 +++++++++---- .../exceptionsManagerHelperTests.spec.ts | 58 ++++ .../tests/renderErrorHandlerTests.spec.ts | 257 ++++++++++++++++++ .../tests/renderErrorRendererTests.spec.ts | 149 ++++++++++ 8 files changed, 676 insertions(+), 59 deletions(-) create mode 100644 packages/react-native/src/common/exceptionsManagerHelper.ts create mode 100644 packages/react-native/tests/exceptionsManagerHelperTests.spec.ts create mode 100644 packages/react-native/tests/renderErrorHandlerTests.spec.ts create mode 100644 packages/react-native/tests/renderErrorRendererTests.spec.ts 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]); + }); +});