Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/rn-render-error-capture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-react-native': patch
---

Fix React render errors that crash the app not being captured on React Native 0.78 and newer when `errorTracking.autocapture.uncaughtExceptions` is enabled.
88 changes: 81 additions & 7 deletions packages/react-native/src/error-tracking/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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<void>
Expand Down Expand Up @@ -39,12 +40,51 @@ export function trackUncaughtExceptions(tracker: UncaughtExceptionHook): () => v
if (!subscription) {
const previousHandler = errorUtils.getGlobalHandler()
const trackers = new Set<UncaughtExceptionHook>()
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. Each fatal is
// captured once while it is in flight; a repeated report is only forwarded, once the first
// 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<unknown, { forwarding: boolean; forwarded: Promise<void> }>()
const forwardedFatals = new WeakSet<object>()
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) {
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<void>((resolve) => (markForwarded = resolve)) }
fatalsInFlight.set(error, fatal)
forwardFatal = () => {
fatal.forwarding = true
if (typeof error === 'object' && error !== null) {
forwardedFatals.add(error)
}
markForwarded()
try {
forward()
} finally {
fatalsInFlight.delete(error)
Comment thread
turnipdabeets marked this conversation as resolved.
Resolved
}
}
}
const pending: Promise<void>[] = []
try {
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(() => {}))
}
Expand All @@ -55,32 +95,37 @@ 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)
forwardFatal()
}
}
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)
forwardFatal()
}
}
}
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: () => {
// Leave another SDK's chain intact, but retire this empty subscription in case it is later detached.
if (errorUtils.getGlobalHandler?.() === handler) {
errorUtils.setGlobalHandler?.(previousHandler)
}
restoreExceptionsManager()
uncaughtExceptionSubscriptions.delete(errorUtils)
},
}
Expand All @@ -97,6 +142,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
Comment thread
turnipdabeets marked this conversation as resolved.
Resolved
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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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')
Comment thread
turnipdabeets marked this conversation as resolved.
Resolved
// CommonJS `module.exports` up to RN 0.78, an ES default export from 0.79.
OptionalReactNativeExceptionsManager = exceptionsManager?.default ?? exceptionsManager
} catch {}
}
Original file line number Diff line number Diff line change
@@ -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
90 changes: 90 additions & 0 deletions packages/react-native/test/optional-exceptions-manager.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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')
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.
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', () => {
expect(metroDependencies(LOADER)).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'react-native/Libraries/Core/ExceptionsManager',
data: expect.objectContaining({ isOptional: true }),
}),
])
)
})

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() {} }'],
[
'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',
})
})
})
Loading
Loading