From 922eca401d832e46dc81010d12dc54bdf914b9b7 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 13 Oct 2026 14:51:22 -0700 Subject: [PATCH 1/6] fix(react-native): capture fatal React render errors Wrap ExceptionsManager.handleException so React 19 root onUncaughtError fatals reach the uncaught-exception fatal path. --- .changeset/rn-render-error-capture.md | 5 + .../react-native/src/error-tracking/utils.ts | 59 +++++++- .../OptionalReactNativeExceptionsManager.ts | 19 +++ .../test/optional-exceptions-manager.spec.ts | 81 ++++++++++ .../test/render-error-capture.spec.ts | 143 ++++++++++++++++++ 5 files changed, 300 insertions(+), 7 deletions(-) create mode 100644 .changeset/rn-render-error-capture.md create mode 100644 packages/react-native/src/optional/OptionalReactNativeExceptionsManager.ts create mode 100644 packages/react-native/test/optional-exceptions-manager.spec.ts create mode 100644 packages/react-native/test/render-error-capture.spec.ts diff --git a/.changeset/rn-render-error-capture.md b/.changeset/rn-render-error-capture.md new file mode 100644 index 0000000000..aaa3fbaedd --- /dev/null +++ b/.changeset/rn-render-error-capture.md @@ -0,0 +1,5 @@ +--- +'posthog-react-native': patch +--- + +Capture React render errors that crash the app on React Native 0.78 and newer as fatal exceptions with their JavaScript stack trace when `errorTracking.autocapture.uncaughtExceptions` is enabled diff --git a/packages/react-native/src/error-tracking/utils.ts b/packages/react-native/src/error-tracking/utils.ts index 8a2cd177a7..3eb7a9c126 100644 --- a/packages/react-native/src/error-tracking/utils.ts +++ b/packages/react-native/src/error-tracking/utils.ts @@ -1,10 +1,12 @@ import { isPromise, safeSetTimeout } from '@posthog/core' import { GLOBAL_OBJ, isHermes, isWeb } from '../utils' +import { OptionalReactNativeExceptionsManager } from '../optional/OptionalReactNativeExceptionsManager' type ExceptionHook = (error: unknown, isFatal: boolean, syntheticException?: Error) => void type UncaughtExceptionHook = (error: unknown, isFatal: boolean) => void | Promise const FATAL_PERSISTENCE_TIMEOUT_MS = 2000 +const NO_FATAL = Symbol('no fatal') export function trackUnhandledRejections(tracker: ExceptionHook): void { if ( @@ -39,12 +41,21 @@ export function trackUncaughtExceptions(tracker: UncaughtExceptionHook): () => v if (!subscription) { const previousHandler = errorUtils.getGlobalHandler() const trackers = new Set() - const handler = (error: Error, isFatal: boolean): void => { + // The same fatal can arrive through both hooks: the ErrorUtils handler forwards to React + // Native's default handler, which calls ExceptionsManager.handleException. + let lastFatal: unknown = NO_FATAL + const dispatch = (error: unknown, isFatal: boolean, forward: () => void): void => { const pending: Promise[] = [] try { + if (isFatal) { + if (lastFatal === error) { + return + } + lastFatal = error + } for (const callback of Array.from(trackers)) { try { - const result = callback(error, isFatal ?? false) + const result = callback(error, isFatal) if (isPromise(result)) { pending.push(Promise.resolve(result).catch(() => {})) } @@ -55,25 +66,29 @@ export function trackUncaughtExceptions(tracker: UncaughtExceptionHook): () => v } finally { if (isFatal && pending.length > 0) { let forwarded = false - const forward = () => { + const forwardOnce = () => { if (!forwarded) { forwarded = true - previousHandler?.(error, isFatal) + forward() } } - const deadline = safeSetTimeout(forward, FATAL_PERSISTENCE_TIMEOUT_MS) + const deadline = safeSetTimeout(forwardOnce, FATAL_PERSISTENCE_TIMEOUT_MS) void Promise.all(pending).then(() => { if (!forwarded) { clearTimeout(deadline) // Keep errors thrown by the previous handler outside Promise rejection handling. - safeSetTimeout(forward, 0) + safeSetTimeout(forwardOnce, 0) } }) } else { - previousHandler?.(error, isFatal) + forward() } } } + const handler = (error: Error, isFatal: boolean): void => { + dispatch(error, isFatal ?? false, () => previousHandler?.(error, isFatal)) + } + const restoreExceptionsManager = wrapExceptionsManager((error, forward) => dispatch(error, true, forward)) subscription = { trackers, restore: () => { @@ -81,6 +96,7 @@ export function trackUncaughtExceptions(tracker: UncaughtExceptionHook): () => v if (errorUtils.getGlobalHandler?.() === handler) { errorUtils.setGlobalHandler?.(previousHandler) } + restoreExceptionsManager() uncaughtExceptionSubscriptions.delete(errorUtils) }, } @@ -97,6 +113,35 @@ export function trackUncaughtExceptions(tracker: UncaughtExceptionHook): () => v } } +// React 19 reports uncaught render errors from the root `onUncaughtError` straight to +// ExceptionsManager.handleException(error, true), which never reaches ErrorUtils. Only fatal +// calls are intercepted; errors caught by an error boundary arrive with isFatal false. +function wrapExceptionsManager(onFatal: (error: unknown, forward: () => void) => void): () => void { + try { + const exceptionsManager = OptionalReactNativeExceptionsManager + const original = exceptionsManager?.handleException + if (!exceptionsManager || typeof original !== 'function') { + return () => {} + } + const wrapped = (error: unknown, isFatal: boolean): void => { + if (!isFatal) { + return original.call(exceptionsManager, error, isFatal) + } + onFatal(error, () => original.call(exceptionsManager, error, isFatal)) + } + exceptionsManager.handleException = wrapped + return () => { + try { + if (exceptionsManager.handleException === wrapped) { + exceptionsManager.handleException = original + } + } catch {} + } + } catch { + return () => {} + } +} + export function trackConsole(level: string, tracker: ExceptionHook): void { const con = console as any if (!con) { diff --git a/packages/react-native/src/optional/OptionalReactNativeExceptionsManager.ts b/packages/react-native/src/optional/OptionalReactNativeExceptionsManager.ts new file mode 100644 index 0000000000..2244dbae5c --- /dev/null +++ b/packages/react-native/src/optional/OptionalReactNativeExceptionsManager.ts @@ -0,0 +1,19 @@ +import { Platform } from 'react-native' + +// React Native's internal exception reporter. React 19's root `onUncaughtError` and +// `onCaughtError` callbacks report through `handleException` without going through ErrorUtils. +// Callers read `handleException` off this object at call time, so it can be wrapped in place. +export type ReactNativeExceptionsManager = { + handleException?: (error: unknown, isFatal: boolean) => void +} + +export let OptionalReactNativeExceptionsManager: ReactNativeExceptionsManager | undefined + +if (Platform.OS !== 'web') { + try { + // Metro recognizes optional dependencies only when require is directly inside the try block. + const exceptionsManager = require('react-native/Libraries/Core/ExceptionsManager') + // CommonJS `module.exports` up to RN 0.78, an ES default export from 0.79. + OptionalReactNativeExceptionsManager = exceptionsManager?.default ?? exceptionsManager + } catch {} +} diff --git a/packages/react-native/test/optional-exceptions-manager.spec.ts b/packages/react-native/test/optional-exceptions-manager.spec.ts new file mode 100644 index 0000000000..60f96868df --- /dev/null +++ b/packages/react-native/test/optional-exceptions-manager.spec.ts @@ -0,0 +1,81 @@ +import { execFileSync } from 'child_process' +import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, rmSync, readFileSync, realpathSync } from 'fs' +import { createRequire } from 'module' +import { tmpdir } from 'os' +import { join, resolve } from 'path' + +const LOADER = resolve(__dirname, '../dist/optional/OptionalReactNativeExceptionsManager.js') + +// test:unit depends on build in Turbo. Exercise the emitted loader with Node's actual require +// resolution, outside Vitest's module mocks. +const loadExceptionsManager = (platform: string, moduleSource?: string): unknown => { + const directory = mkdtempSync(join(tmpdir(), 'posthog-optional-exceptions-manager-')) + try { + copyFileSync(LOADER, join(directory, 'loader.cjs')) + const nativeDirectory = join(directory, 'node_modules/react-native') + mkdirSync(join(nativeDirectory, 'Libraries/Core'), { recursive: true }) + writeFileSync(join(nativeDirectory, 'index.js'), `exports.Platform = { OS: ${JSON.stringify(platform)} }`) + if (moduleSource !== undefined) { + writeFileSync(join(nativeDirectory, 'Libraries/Core/ExceptionsManager.js'), moduleSource) + } + return JSON.parse( + execFileSync( + process.execPath, + [ + '-e', + "const m = require('./loader.cjs').OptionalReactNativeExceptionsManager; console.log(JSON.stringify({ handleException: typeof m?.handleException, loaded: global.loaded === true }))", + ], + { cwd: directory, encoding: 'utf8' } + ) + ) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} + +describe('OptionalReactNativeExceptionsManager built loader', () => { + it('marks the ExceptionsManager deep import as optional in the emitted Metro dependency graph', () => { + // Resolve from Metro's real location so pnpm's transitive Babel dependency is available. + const metroRequire = createRequire(realpathSync(resolve(__dirname, '../node_modules/metro/package.json'))) + const parser = metroRequire('@babel/parser') + const collectDependencies = metroRequire('./src/ModuleGraph/worker/collectDependencies.js') + const { dependencies } = collectDependencies(parser.parse(readFileSync(LOADER, 'utf8')), { + asyncRequireModulePath: 'metro-runtime/src/modules/asyncRequire', + dynamicRequires: 'reject', + inlineableCalls: [], + keepRequireNames: true, + allowOptionalDependencies: true, + unstable_allowRequireContext: false, + }) + + expect(dependencies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'react-native/Libraries/Core/ExceptionsManager', + data: expect.objectContaining({ isOptional: true }), + }), + ]) + ) + }) + + it.each([ + ['CommonJS exports (RN 0.78 and older)', 'global.loaded = true; module.exports = { handleException() {} }'], + [ + 'an ES default export (RN 0.79 and newer)', + 'global.loaded = true; exports.__esModule = true; exports.default = { handleException() {} }', + ], + ])('resolves handleException from %s', (_, source) => { + expect(loadExceptionsManager('ios', source)).toEqual({ loaded: true, handleException: 'function' }) + }) + + it('tolerates a missing module', () => { + expect(loadExceptionsManager('android')).toEqual({ loaded: false, handleException: 'undefined' }) + }) + + it('does not load the module on web', () => { + expect(loadExceptionsManager('web', 'global.loaded = true; module.exports = { handleException() {} }')).toEqual({ + loaded: false, + handleException: 'undefined', + }) + }) +}) diff --git a/packages/react-native/test/render-error-capture.spec.ts b/packages/react-native/test/render-error-capture.spec.ts new file mode 100644 index 0000000000..8c3ccbfa86 --- /dev/null +++ b/packages/react-native/test/render-error-capture.spec.ts @@ -0,0 +1,143 @@ +import { trackUncaughtExceptions } from '../src/error-tracking/utils' +import { ErrorTracking } from '../src/error-tracking' +import { createMockLogger, createMockPostHog } from './test-utils' + +type Handler = (error: unknown, isFatal?: boolean) => void +type ExceptionsManager = { handleException?: (error: unknown, isFatal: boolean) => void } + +const holder = vi.hoisted(() => ({ exceptionsManager: undefined as ExceptionsManager | undefined })) + +vi.mock('../src/optional/OptionalReactNativeExceptionsManager', () => ({ + get OptionalReactNativeExceptionsManager() { + return holder.exceptionsManager + }, +})) + +describe('React render error capture through ExceptionsManager.handleException', () => { + let handleException: ReturnType + let exceptionsManager: { handleException: (error: unknown, isFatal: boolean) => void } + let current: Handler + let previous: ReturnType + + beforeEach(() => { + vi.useFakeTimers() + handleException = vi.fn() + exceptionsManager = { handleException } + holder.exceptionsManager = exceptionsManager + // React Native's default global handler reports through ExceptionsManager. + previous = vi.fn((error: unknown, isFatal?: boolean) => exceptionsManager.handleException(error, !!isFatal)) + current = previous + vi.stubGlobal('ErrorUtils', { + getGlobalHandler: () => current, + setGlobalHandler: (handler: Handler) => { + current = handler + }, + }) + }) + + afterEach(() => { + vi.clearAllTimers() + vi.unstubAllGlobals() + }) + + it('captures a fatal render error once and reports it to React Native after persistence', async () => { + let persist!: () => void + const tracker = vi.fn( + () => + new Promise((resolve) => { + persist = resolve + }) + ) + trackUncaughtExceptions(tracker) + const error = new Error('render crash') + + exceptionsManager.handleException(error, true) + + expect(tracker.mock.calls).toEqual([[error, true]]) + expect(handleException).not.toHaveBeenCalled() + persist() + await vi.advanceTimersByTimeAsync(0) + expect(handleException.mock.calls).toEqual([[error, true]]) + }) + + it('passes errors caught by an error boundary straight through without capturing them', () => { + const tracker = vi.fn() + trackUncaughtExceptions(tracker) + const error = new Error('caught by boundary') + + exceptionsManager.handleException(error, false) + + expect(tracker).not.toHaveBeenCalled() + expect(handleException.mock.calls).toEqual([[error, false]]) + }) + + it('captures an ErrorUtils fatal once when the default handler reports it through ExceptionsManager', async () => { + const tracker = vi.fn(() => Promise.resolve()) + trackUncaughtExceptions(tracker) + const error = new Error('handler crash') + + current(error, true) + await vi.advanceTimersByTimeAsync(0) + + expect(tracker.mock.calls).toEqual([[error, true]]) + expect(previous).toHaveBeenCalledTimes(1) + expect(handleException.mock.calls).toEqual([[error, true]]) + }) + + it('restores the original handleException after the last subscriber is removed', () => { + const remove = trackUncaughtExceptions(vi.fn()) + expect(exceptionsManager.handleException).not.toBe(handleException) + remove() + expect(exceptionsManager.handleException).toBe(handleException) + }) + + it.each([ + ['missing', () => undefined], + ['without handleException', () => ({})], + [ + 'not writable', + () => + Object.defineProperty({}, 'handleException', { + get: () => vi.fn(), + set: () => { + throw new Error('read-only') + }, + }), + ], + ])('keeps ErrorUtils capture working when ExceptionsManager is %s', (_, create) => { + holder.exceptionsManager = create() as ExceptionsManager | undefined + const tracker = vi.fn() + let remove!: () => void + expect(() => { + remove = trackUncaughtExceptions(tracker) + }).not.toThrow() + const error = new Error('handler crash') + current(error, true) + expect(tracker.mock.calls).toEqual([[error, true]]) + expect(() => remove()).not.toThrow() + }) + + it('routes a fatal render error through the fatal capture path', async () => { + const posthog = createMockPostHog() + const errorTracking = new ErrorTracking(posthog, { autocapture: { uncaughtExceptions: true } }, createMockLogger()) + const error = new Error('render crash') + + exceptionsManager.handleException(error, true) + await vi.advanceTimersByTimeAsync(0) + + expect(posthog.captureException.mock.calls).toEqual([ + [error, { $exception_level: 'fatal' }, { mechanism: { type: 'onuncaughtexception', handled: false } }], + ]) + expect(handleException.mock.calls).toEqual([[error, true]]) + errorTracking.shutdown() + }) + + it('leaves handleException untouched when uncaught exception autocapture is off', () => { + const posthog = createMockPostHog() + new ErrorTracking(posthog, { autocapture: { uncaughtExceptions: false } }, createMockLogger()) + + expect(exceptionsManager.handleException).toBe(handleException) + exceptionsManager.handleException(new Error('render crash'), true) + expect(posthog.captureException).not.toHaveBeenCalled() + }) +}) From 04df8ff8ea5489eb8f92f7725855dd0d96361469 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 13 Oct 2026 14:57:45 -0700 Subject: [PATCH 2/6] fix(react-native): capture a repeated fatal after its duplicate is passed through --- packages/react-native/src/error-tracking/utils.ts | 4 +++- .../react-native/test/render-error-capture.spec.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/react-native/src/error-tracking/utils.ts b/packages/react-native/src/error-tracking/utils.ts index 3eb7a9c126..731cfa0563 100644 --- a/packages/react-native/src/error-tracking/utils.ts +++ b/packages/react-native/src/error-tracking/utils.ts @@ -42,13 +42,15 @@ export function trackUncaughtExceptions(tracker: UncaughtExceptionHook): () => v const previousHandler = errorUtils.getGlobalHandler() const trackers = new Set() // The same fatal can arrive through both hooks: the ErrorUtils handler forwards to React - // Native's default handler, which calls ExceptionsManager.handleException. + // Native's default handler, which calls ExceptionsManager.handleException. The second + // arrival is only forwarded, by the `finally` below. let lastFatal: unknown = NO_FATAL const dispatch = (error: unknown, isFatal: boolean, forward: () => void): void => { const pending: Promise[] = [] try { if (isFatal) { if (lastFatal === error) { + lastFatal = NO_FATAL return } lastFatal = error diff --git a/packages/react-native/test/render-error-capture.spec.ts b/packages/react-native/test/render-error-capture.spec.ts index 8c3ccbfa86..c5f5415002 100644 --- a/packages/react-native/test/render-error-capture.spec.ts +++ b/packages/react-native/test/render-error-capture.spec.ts @@ -84,6 +84,20 @@ describe('React render error capture through ExceptionsManager.handleException', expect(handleException.mock.calls).toEqual([[error, true]]) }) + it('captures a repeated fatal again after the duplicate report was passed through', () => { + const tracker = vi.fn() + trackUncaughtExceptions(tracker) + + current('boom', true) + current('boom', true) + + expect(tracker.mock.calls).toEqual([ + ['boom', true], + ['boom', true], + ]) + expect(handleException).toHaveBeenCalledTimes(2) + }) + it('restores the original handleException after the last subscriber is removed', () => { const remove = trackUncaughtExceptions(vi.fn()) expect(exceptionsManager.handleException).not.toBe(handleException) From c819d3b1fe504806830c0f8926d9d16283b6d0d8 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 13 Oct 2026 15:44:12 -0700 Subject: [PATCH 3/6] chore: describe the render-error capture as a fix in the changeset --- .changeset/rn-render-error-capture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/rn-render-error-capture.md b/.changeset/rn-render-error-capture.md index aaa3fbaedd..1eff74c57e 100644 --- a/.changeset/rn-render-error-capture.md +++ b/.changeset/rn-render-error-capture.md @@ -2,4 +2,4 @@ 'posthog-react-native': patch --- -Capture React render errors that crash the app on React Native 0.78 and newer as fatal exceptions with their JavaScript stack trace when `errorTracking.autocapture.uncaughtExceptions` is enabled +Fix React render errors that crash the app not being captured on React Native 0.78 and newer when `errorTracking.autocapture.uncaughtExceptions` is enabled. From 6b2961914fe93a16c0f4618bac7e75603fa060df Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 13 Oct 2026 15:50:53 -0700 Subject: [PATCH 4/6] fix(react-native): dedupe each in-flight fatal and hold its duplicates until forwarded --- .../react-native/src/error-tracking/utils.ts | 44 ++++++++++++------ .../test/render-error-capture.spec.ts | 46 +++++++++++++++++++ 2 files changed, 77 insertions(+), 13 deletions(-) diff --git a/packages/react-native/src/error-tracking/utils.ts b/packages/react-native/src/error-tracking/utils.ts index 731cfa0563..ea563a92b7 100644 --- a/packages/react-native/src/error-tracking/utils.ts +++ b/packages/react-native/src/error-tracking/utils.ts @@ -6,7 +6,6 @@ type ExceptionHook = (error: unknown, isFatal: boolean, syntheticException?: Err type UncaughtExceptionHook = (error: unknown, isFatal: boolean) => void | Promise const FATAL_PERSISTENCE_TIMEOUT_MS = 2000 -const NO_FATAL = Symbol('no fatal') export function trackUnhandledRejections(tracker: ExceptionHook): void { if ( @@ -42,19 +41,38 @@ export function trackUncaughtExceptions(tracker: UncaughtExceptionHook): () => v const previousHandler = errorUtils.getGlobalHandler() const trackers = new Set() // The same fatal can arrive through both hooks: the ErrorUtils handler forwards to React - // Native's default handler, which calls ExceptionsManager.handleException. The second - // arrival is only forwarded, by the `finally` below. - let lastFatal: unknown = NO_FATAL + // Native's default handler, which calls ExceptionsManager.handleException. Each fatal is + // captured once while it is in flight; a repeated report is only forwarded, once the first + // report has been forwarded. + const fatalsInFlight = new Map }>() const dispatch = (error: unknown, isFatal: boolean, forward: () => void): void => { - const pending: Promise[] = [] - try { - if (isFatal) { - if (lastFatal === error) { - lastFatal = NO_FATAL - return + const inFlight = isFatal ? fatalsInFlight.get(error) : undefined + if (inFlight) { + if (inFlight.forwarding) { + forward() + } else { + // Keep errors thrown by the previous handler outside Promise rejection handling. + void inFlight.forwarded.then(() => safeSetTimeout(forward, 0)) + } + return + } + let forwardFatal = forward + if (isFatal) { + let markForwarded!: () => void + const fatal = { forwarding: false, forwarded: new Promise((resolve) => (markForwarded = resolve)) } + fatalsInFlight.set(error, fatal) + forwardFatal = () => { + fatal.forwarding = true + markForwarded() + try { + forward() + } finally { + fatalsInFlight.delete(error) } - lastFatal = error } + } + const pending: Promise[] = [] + try { for (const callback of Array.from(trackers)) { try { const result = callback(error, isFatal) @@ -71,7 +89,7 @@ export function trackUncaughtExceptions(tracker: UncaughtExceptionHook): () => v const forwardOnce = () => { if (!forwarded) { forwarded = true - forward() + forwardFatal() } } const deadline = safeSetTimeout(forwardOnce, FATAL_PERSISTENCE_TIMEOUT_MS) @@ -83,7 +101,7 @@ export function trackUncaughtExceptions(tracker: UncaughtExceptionHook): () => v } }) } else { - forward() + forwardFatal() } } } diff --git a/packages/react-native/test/render-error-capture.spec.ts b/packages/react-native/test/render-error-capture.spec.ts index c5f5415002..9434dacf1e 100644 --- a/packages/react-native/test/render-error-capture.spec.ts +++ b/packages/react-native/test/render-error-capture.spec.ts @@ -98,6 +98,52 @@ describe('React render error capture through ExceptionsManager.handleException', expect(handleException).toHaveBeenCalledTimes(2) }) + it('captures each of two overlapping ErrorUtils fatals once', async () => { + const tracker = vi.fn(() => Promise.resolve()) + trackUncaughtExceptions(tracker) + const first = new Error('first crash') + const second = new Error('second crash') + + current(first, true) + current(second, true) + await vi.advanceTimersByTimeAsync(0) + + expect(tracker.mock.calls).toEqual([ + [first, true], + [second, true], + ]) + expect(handleException.mock.calls).toEqual([ + [first, true], + [second, true], + ]) + }) + + it('holds a duplicate fatal report until the first report has been persisted', async () => { + let persist!: () => void + const tracker = vi.fn( + () => + new Promise((resolve) => { + persist = resolve + }) + ) + trackUncaughtExceptions(tracker) + const error = new Error('render crash') + + exceptionsManager.handleException(error, true) + exceptionsManager.handleException(error, true) + + expect(tracker.mock.calls).toEqual([[error, true]]) + expect(handleException).not.toHaveBeenCalled() + persist() + await vi.advanceTimersByTimeAsync(0) + expect(handleException.mock.calls).toEqual([[error, true]]) + await vi.runOnlyPendingTimersAsync() + expect(handleException.mock.calls).toEqual([ + [error, true], + [error, true], + ]) + }) + it('restores the original handleException after the last subscriber is removed', () => { const remove = trackUncaughtExceptions(vi.fn()) expect(exceptionsManager.handleException).not.toBe(handleException) From 9df50c6bab6516a005bf16fe2b0bf1a9cbafd68d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 22 Oct 2026 10:42:46 -0400 Subject: [PATCH 5/6] fix(react-native): capture a fatal once when the previous handler forwards it later Forwarded fatal Error objects pass straight through, so a previous handler that reports to React Native after it returns (Sentry) no longer causes a second capture. --- .../react-native/src/error-tracking/utils.ts | 11 ++++++++++- .../test/render-error-capture.spec.ts | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/react-native/src/error-tracking/utils.ts b/packages/react-native/src/error-tracking/utils.ts index ea563a92b7..897c24fcb4 100644 --- a/packages/react-native/src/error-tracking/utils.ts +++ b/packages/react-native/src/error-tracking/utils.ts @@ -43,9 +43,15 @@ export function trackUncaughtExceptions(tracker: UncaughtExceptionHook): () => v // The same fatal can arrive through both hooks: the ErrorUtils handler forwards to React // Native's default handler, which calls ExceptionsManager.handleException. Each fatal is // captured once while it is in flight; a repeated report is only forwarded, once the first - // report has been forwarded. + // report has been forwarded. A previous handler can report the fatal to React Native after it + // returns (Sentry flushes first), so forwarded Error objects are also only forwarded again. const fatalsInFlight = new Map }>() + const forwardedFatals = new WeakSet() const dispatch = (error: unknown, isFatal: boolean, forward: () => void): void => { + if (isFatal && typeof error === 'object' && error !== null && forwardedFatals.has(error)) { + forward() + return + } const inFlight = isFatal ? fatalsInFlight.get(error) : undefined if (inFlight) { if (inFlight.forwarding) { @@ -63,6 +69,9 @@ export function trackUncaughtExceptions(tracker: UncaughtExceptionHook): () => v fatalsInFlight.set(error, fatal) forwardFatal = () => { fatal.forwarding = true + if (typeof error === 'object' && error !== null) { + forwardedFatals.add(error) + } markForwarded() try { forward() diff --git a/packages/react-native/test/render-error-capture.spec.ts b/packages/react-native/test/render-error-capture.spec.ts index 9434dacf1e..e129a4b507 100644 --- a/packages/react-native/test/render-error-capture.spec.ts +++ b/packages/react-native/test/render-error-capture.spec.ts @@ -144,6 +144,22 @@ describe('React render error capture through ExceptionsManager.handleException', ]) }) + it('captures a fatal once when the previous handler reports it to React Native later', async () => { + // Sentry's global handler awaits its own flush before calling the handler it replaced. + previous.mockImplementation((error: unknown, isFatal?: boolean) => { + setTimeout(() => exceptionsManager.handleException(error, !!isFatal), 2000) + }) + const tracker = vi.fn(() => Promise.resolve()) + trackUncaughtExceptions(tracker) + const error = new Error('handler crash') + + current(error, true) + await vi.advanceTimersByTimeAsync(2000) + + expect(tracker.mock.calls).toEqual([[error, true]]) + expect(handleException.mock.calls).toEqual([[error, true]]) + }) + it('restores the original handleException after the last subscriber is removed', () => { const remove = trackUncaughtExceptions(vi.fn()) expect(exceptionsManager.handleException).not.toBe(handleException) From 8611ecff924bf27d7a04c694c61e557eb60540c4 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 22 Oct 2026 11:08:57 -0400 Subject: [PATCH 6/6] fix(react-native): keep ExceptionsManager out of web bundles React Native 0.79 and older have no web implementation of the modules it imports, so Expo web bundling failed. --- ...ptionalReactNativeExceptionsManager.web.ts | 5 +++ .../test/optional-exceptions-manager.spec.ts | 37 ++++++++++++------- 2 files changed, 28 insertions(+), 14 deletions(-) create mode 100644 packages/react-native/src/optional/OptionalReactNativeExceptionsManager.web.ts diff --git a/packages/react-native/src/optional/OptionalReactNativeExceptionsManager.web.ts b/packages/react-native/src/optional/OptionalReactNativeExceptionsManager.web.ts new file mode 100644 index 0000000000..94e5c6e85c --- /dev/null +++ b/packages/react-native/src/optional/OptionalReactNativeExceptionsManager.web.ts @@ -0,0 +1,5 @@ +import type { ReactNativeExceptionsManager } from './OptionalReactNativeExceptionsManager' + +// Web bundles resolve this file instead, so React Native's ExceptionsManager and its native-only +// imports stay out of the web dependency graph. +export const OptionalReactNativeExceptionsManager: ReactNativeExceptionsManager | undefined = undefined diff --git a/packages/react-native/test/optional-exceptions-manager.spec.ts b/packages/react-native/test/optional-exceptions-manager.spec.ts index 60f96868df..70034bd190 100644 --- a/packages/react-native/test/optional-exceptions-manager.spec.ts +++ b/packages/react-native/test/optional-exceptions-manager.spec.ts @@ -5,6 +5,22 @@ import { tmpdir } from 'os' import { join, resolve } from 'path' const LOADER = resolve(__dirname, '../dist/optional/OptionalReactNativeExceptionsManager.js') +const WEB_LOADER = resolve(__dirname, '../dist/optional/OptionalReactNativeExceptionsManager.web.js') + +const metroDependencies = (file: string): { name: string; data: { isOptional?: boolean } }[] => { + // Resolve from Metro's real location so pnpm's transitive Babel dependency is available. + const metroRequire = createRequire(realpathSync(resolve(__dirname, '../node_modules/metro/package.json'))) + const parser = metroRequire('@babel/parser') + const collectDependencies = metroRequire('./src/ModuleGraph/worker/collectDependencies.js') + return collectDependencies(parser.parse(readFileSync(file, 'utf8')), { + asyncRequireModulePath: 'metro-runtime/src/modules/asyncRequire', + dynamicRequires: 'reject', + inlineableCalls: [], + keepRequireNames: true, + allowOptionalDependencies: true, + unstable_allowRequireContext: false, + }).dependencies +} // test:unit depends on build in Turbo. Exercise the emitted loader with Node's actual require // resolution, outside Vitest's module mocks. @@ -35,20 +51,7 @@ const loadExceptionsManager = (platform: string, moduleSource?: string): unknown describe('OptionalReactNativeExceptionsManager built loader', () => { it('marks the ExceptionsManager deep import as optional in the emitted Metro dependency graph', () => { - // Resolve from Metro's real location so pnpm's transitive Babel dependency is available. - const metroRequire = createRequire(realpathSync(resolve(__dirname, '../node_modules/metro/package.json'))) - const parser = metroRequire('@babel/parser') - const collectDependencies = metroRequire('./src/ModuleGraph/worker/collectDependencies.js') - const { dependencies } = collectDependencies(parser.parse(readFileSync(LOADER, 'utf8')), { - asyncRequireModulePath: 'metro-runtime/src/modules/asyncRequire', - dynamicRequires: 'reject', - inlineableCalls: [], - keepRequireNames: true, - allowOptionalDependencies: true, - unstable_allowRequireContext: false, - }) - - expect(dependencies).toEqual( + expect(metroDependencies(LOADER)).toEqual( expect.arrayContaining([ expect.objectContaining({ name: 'react-native/Libraries/Core/ExceptionsManager', @@ -58,6 +61,12 @@ describe('OptionalReactNativeExceptionsManager built loader', () => { ) }) + it('keeps React Native out of the web loader dependency graph', () => { + // React Native 0.79 and older have no web implementation of the modules ExceptionsManager + // imports, so web bundling fails if Metro reaches it. + expect(metroDependencies(WEB_LOADER)).toEqual([]) + }) + it.each([ ['CommonJS exports (RN 0.78 and older)', 'global.loaded = true; module.exports = { handleException() {} }'], [