Repository navigation
fix(react-native): capture fatal React render errors - #5293
turnipdabeets wants to merge 4 commits into
Conversation
Wrap ExceptionsManager.handleException so React 19 root onUncaughtError fatals reach the uncaught-exception fatal path.
| if (isFatal) { | ||
| if (lastFatal === error) { | ||
| lastFatal = NO_FATAL | ||
| return | ||
| } | ||
| lastFatal = error |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| if (lastFatal === error) { | ||
| lastFatal = NO_FATAL | ||
| return |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
Coherence2 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)
Chokepoints: none declared. Spec gaps: 2 entrances with outside or unknown trust and no traced control, and 10 more held by the adoption baseline
Close one with |
…s until forwarded
| } | ||
| onFatal(error, () => original.call(exceptionsManager, error, isFatal)) | ||
| } | ||
| exceptionsManager.handleException = wrapped |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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
onUncaughtErrorcallback, which callsExceptionsManager.handleException(error, true)directly. It never reachesErrorUtils, the only hookposthog-react-nativeuses, so the SDK's fatal capture never runs. On React 18 (RN < 0.78) React rethrew render errors toErrorUtils, so they were captured.Reproduced on device, RN 0.86.2 new architecture, Release build:
$exceptionis sent.RCTFatalExceptionand the app aborts. The plugin drops that native report as a duplicate of a JS capture that never happened, so no$exceptionis 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
ExceptionsManager.handleException(optional deep import ofreact-native/Libraries/Core/ExceptionsManager, feature-detected) so fatal calls go through the same tracker as theErrorUtilshandler. Render fatals then reachhandleFatalException, with the same durable native capture and persistence wait as any other fatal. The mechanism staysonuncaughtexception.ErrorUtilshook stays the primary path. If the module is missing or can't be wrapped, only the render path is skipped.handleExceptionis not captured again. Fatals still being persisted are tracked per error, and a duplicate report of one waits until the original has been forwarded.errorTracking.autocapture.uncaughtExceptions.Rejected alternatives:
unstable_setExceptionDecorator: only runs insidereportException, which is skipped when native handles the exception, and it holds a single slot.RN$handleException: a read-only global, andhandleExceptioncalls 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 itsexportsmap. A scheduled contract check againstreact-native@latestand@nightlyis a possible follow-up.Known gap: on RN 0.78 only, with Metro's non-default
experimentalImportSupport: trueplusinlineRequires: false,ErrorHandlerscaches its namedhandleExceptionimport at startup, so render crashes bypass the wrap there. RN 0.79+ callsExceptionsManager.handleExceptionthrough 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-nativevitest (75 files, 1261 tests), lint, format and typecheck pass. New:test/render-error-capture.spec.tsandtest/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.$exceptionevents per crash; same-uuid pairs at ingestion are counted once.genericerror, no fataluseAlwaysAvailableJSErrorHandlingflag on, and running alongside Sentry.Release info Sub-libraries affected
Libraries affected
Checklist
If releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
examples/example-expo-57.handleExceptionwrap, keepingErrorUtilsas 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.__DEV__, LogBox's own internal failures can now be captured as fatals. Web bundles include RN'sExceptionsManager, but it never runs there. Nothing logs when the deep import is unavailable.🤖 Generated with Claude Code