Skip to content

fix(react-native): capture fatal React render errors - #5293

Draft
turnipdabeets wants to merge 4 commits into
mainfrom
fix/rn-render-error-capture
Draft

turnipdabeets wants to merge 4 commits into
mainfrom
fix/rn-render-error-capture

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Problem

React render errors that crash the app no longer reach error tracking on React Native 0.78 and newer.

React 19 (RN 0.78+) reports an uncaught render error through the root onUncaughtError callback, which calls ExceptionsManager.handleException(error, true) directly. It never reaches ErrorUtils, the only hook posthog-react-native uses, so the SDK's fatal capture never runs. On React 18 (RN < 0.78) React rethrew render errors to ErrorUtils, so they were captured.

Reproduced on device, RN 0.86.2 new architecture, Release build:

  • Android: the app does not crash. The React host is torn down and the screen goes blank. No $exception is sent.
  • iOS: RN raises RCTFatalException and the app aborts. The plugin drops that native report as a duplicate of a JS capture that never happened, so no $exception is sent.

A customer reported this on iOS (RN 0.88-rc, Expo 58, new architecture). Related: #5271 makes iOS keep the native report when JS did not capture the crash.

Changes

  • Wrap ExceptionsManager.handleException (optional deep import of react-native/Libraries/Core/ExceptionsManager, feature-detected) so fatal calls go through the same tracker as the ErrorUtils handler. Render fatals then reach handleFatalException, with the same durable native capture and persistence wait as any other fatal. The mechanism stays onuncaughtexception.
  • The ErrorUtils hook stays the primary path. If the module is missing or can't be wrapped, only the render path is skipped.
  • Non-fatal calls (errors caught by an error boundary) pass straight through.
  • One capture per fatal: a handler fatal that RN's default handler forwards to handleException is not captured again. Fatals still being persisted are tracked per error, and a duplicate report of one waits until the original has been forwarded.
  • No new option. Gated by errorTracking.autocapture.uncaughtExceptions.

Rejected alternatives:

  • unstable_setExceptionDecorator: only runs inside reportException, which is skipped when native handles the exception, and it holds a single slot.
  • RN$handleException: a read-only global, and handleException calls it anyway.
  • console.error: RN logs the error there before reporting it, but it can't tell a fatal render error from one an error boundary caught.

Maintenance risk: if RN moves or stops exporting Libraries/Core/ExceptionsManager, render-crash capture silently stops; handler fatals are unaffected. The module has lived at that path from RN 0.70 through today's nightly (0.89), and RN 0.87 still exports ./Libraries/* after narrowing its exports map. A scheduled contract check against react-native@latest and @nightly is a possible follow-up.

Known gap: on RN 0.78 only, with Metro's non-default experimentalImportSupport: true plus inlineRequires: false, ErrorHandlers caches its named handleException import at startup, so render crashes bypass the wrap there. RN 0.79+ calls ExceptionsManager.handleException through the module object and is covered. Handler fatals are captured in every setup.

Composing with #5271

Both hooks feed one tracker, which calls ErrorTracking.onUncaughtException, where #5271 sets its marker. Whichever merges second needs at most a trivial rebase. With today's plugin, iOS sends one event (JS) because the native report is still dropped unconditionally.

Test plan

  • posthog-react-native vitest (75 files, 1261 tests), lint, format and typecheck pass. New: test/render-error-capture.spec.ts and test/optional-exceptions-manager.spec.ts. These cover fatal vs non-fatal, dedup, a missing or throwing module, autocapture off, Metro marking the deep import optional, both module shapes, and nothing loading on web.
  • Device, Release builds, events checked in a test project. Each cell is the number of $exception events per crash; same-uuid pairs at ingestion are counted once.
Setup Render crash (main → this PR) Handler crash Error caught by a boundary
RN 0.86, new arch, Expo 57, Android 0 → 1 fatal, 7 JS frames 1 fatal generic error, no fatal
RN 0.86, new arch, Expo 57, iOS 0 → 1 fatal, 7 JS frames, no native duplicate 1 fatal not run
RN 0.79 (React 19), old arch, bare, Android + iOS 0 → 1 fatal, 23 JS frames 1 fatal 1 error
RN 0.79, old arch, Expo 53 + expo-updates, Android + iOS 0 → 1 fatal, 23 JS frames 1 fatal 1 error
RN 0.76 (React 18), old arch, bare, Android + iOS 1 → 1 (already captured; no double capture) 1 fatal 1 error

Release info Sub-libraries affected

Libraries affected

  • All of them
  • posthog-js (web)
  • posthog-js-lite (web lite)
  • posthog-node
  • posthog-react-native
  • @posthog/react-native-plugin
  • @posthog/react
  • @posthog/ai
  • @posthog/convex
  • @posthog/next
  • @posthog/nextjs-config
  • @posthog/nuxt
  • @posthog/openfeature-node-provider
  • @posthog/openfeature-web-provider
  • @posthog/rollup-plugin
  • @posthog/webpack-plugin
  • @posthog/types
  • @posthog/browser-common

Checklist

  • Tests for new code
  • Accounted for the impact of any changes across different platforms
  • Accounted for backwards compatibility of any changes (no breaking changes!)
  • Took care not to unnecessarily increase the bundle size

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

  • Claude Code (Claude Opus 5.5) with subagents: shell, file edits, GitHub CLI, the PostHog MCP for reading test events, and Android emulator / iOS simulator runs of examples/example-expo-57.
  • Compared the hook options against RN 0.69 to 0.86 and nightly sources and chose the handleException wrap, keeping ErrorUtils as the primary path so a broken deep import can't cost handler fatals. One independent review pass found no blocking issues; it surfaced a dedup memory leak, fixed in the second commit.
  • Notes for reviewers: in __DEV__, LogBox's own internal failures can now be captured as fatals. Web bundles include RN's ExceptionsManager, but it never runs there. Nothing logs when the deep import is unavailable.

🤖 Generated with Claude Code

Wrap ExceptionsManager.handleException so React 19 root onUncaughtError fatals reach the uncaught-exception fatal path.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed c819d3b: 2 Should fix.

Comment on lines +51 to +56
if (isFatal) {
if (lastFatal === error) {
lastFatal = NO_FATAL
return
}
lastFatal = error

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Scope fatal deduplication to each forwarding operation

When two distinct ErrorUtils fatals arrive before persistence completes, the second overwrites lastFatal. Each delayed forward then reaches the wrapped handleException with a different marker, so dispatch captures both errors again and starts another persistence wait. Tracker calls become A, B, A, B; scope duplicate suppression to each forwarding operation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real: two overlapping ErrorUtils fatals were captured A, B, A, B. Fixed in 6b29619 by tracking each in-flight fatal separately (released once it is forwarded); covered by captures each of two overlapping ErrorUtils fatals once in test/render-error-capture.spec.ts, which failed before the fix.

Comment on lines +52 to +54
if (lastFatal === error) {
lastFatal = NO_FATAL
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Preserve the persistence wait when deduplicating overlapping fatals

When two render roots report the same Error object before persistence finishes, dispatch skips the second capture but immediately calls its forward through finally. React Native then handles the fatal while the first capture remains unresolved; on iOS, termination loses the crash report. Scope deduplication to the matching hook handoff and preserve its persistence wait.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real: the duplicate report was forwarded to React Native immediately, before the first capture persisted. Fixed in 6b29619: a duplicate of an in-flight fatal now waits until the first report is forwarded, then forwards; covered by holds a duplicate fatal report until the first report has been persisted, which failed before the fix.

@github-actions

Copy link
Copy Markdown
Contributor

Coherence

2 spec problems · 0 failing chokepoints · 2 spec gaps · 0 guard failures

44 components, 5 bullets (5 invariants, 0 requirements), 2 problems

Spec problems (spec --check)

  • Posthog-js.practice.md:20 practice add an invariant cites 187a5c5, which is no commit in this repository
  • Posthog-js.practice.md:20 practice add an invariant cites 8922ab2, which is no commit in this repository

Chokepoints: none declared.

Spec gaps: 2 entrances with outside or unknown trust and no traced control, and 10 more held by the adoption baseline

  • packages/web/Web.spec.md entrance public api (trust host-app): packages/web
  • packages/webpack-plugin/Webpack-plugin.spec.md entrance webpack plugin (trust build): packages/webpack-plugin

Close one with guard: <chokepoint>, an invariant that names it on its entrances: line, or control: none — <reason>; scaffold control proposes it.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 6b29619: 1 Should fix.

}
onFatal(error, () => original.call(exceptionsManager, error, isFatal))
}
exceptionsManager.handleException = wrapped

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Intercept RN 0.78 renderers that cache the named handler

On RN 0.78 with Metro's experimental import transform and inlineRequires: false, ErrorHandlers caches its named handleException import before SDK setup. Assigning exceptionsManager.handleException leaves that cached function unchanged, so onUncaughtError still bypasses PostHog and render crashes remain uncaptured. Cover the cached-handler path as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed only for a non-default setup: RN 0.78 is the only React 19 release whose ErrorHandlers uses a named handleException import (0.79+ calls ExceptionsManager.handleException, which the wrap intercepts), and Metro caches it only with both experimentalImportSupport: true and inlineRequires: false, while @react-native/metro-config 0.78 defaults to false/true. The cached binding is a closure inside ExceptionsManager that the SDK cannot replace without patching RN internals, so no code change here; ErrorUtils fatals are unaffected, and the gap is listed in the PR description as a known limitation.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant