Skip to content

fix(react-native): stop dropping native crash reports for fatals JS did not see - #5271

Merged
turnipdabeets merged 9 commits into
mainfrom
posthog-self-driving/fixreact-native-stop-dropping-native-c25d01
Oct 14, 2026
Merged

turnipdabeets merged 9 commits into
mainfrom
posthog-self-driving/fixreact-native-stop-dropping-native-c25d01

Conversation

@posthog

@posthog posthog Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Problem

  • React Native apps that crash from an error thrown during render send nothing to error tracking, even with uncaughtExceptions and nativeCrashes both on.
  • With React 19, the root onUncaughtError calls ExceptionsManager.handleException directly. The JS SDK hooks only ErrorUtils.setGlobalHandler, so it never sees these crashes.
  • The native plugins then drop the native report as a duplicate. iOS beforeSend drops every RCTFatalException / JS-error marker, and Android adds JavascriptException to ignoredExceptionTypes. That filter (from #3824) assumes JS always captured the crash first.

Origin

  • Conversations: ticket
  • First signal: 2026-10-09
  • Inbox report: open
  • Likely cause: bd80c7c
  • Task started by: auto-start, after the report was rated P1 and ready to fix

Changes

  • Mechanism: a "JS saw this fatal" marker. Before forwarding a fatal to React Native, the JS fatal handler calls a new plugin method, markFatalExceptionHandled(message), and waits for it. React Native then turns the error into a native crash. Native drops its fatal JS report only when a marker matches it. Fatals JS did not capture (render errors on React 19) are reported natively.
  • The marker is tied to the error. JS passes the first 80 characters of the <name>: <message> text React Native reports for the error ([object Object] for a thrown object). Native drops a report only when its message starts with that text and, for a shorter marker, ends there too, so an empty or short marker can't match a different crash. Native keeps up to 10 markers, so a second fatal before the crash doesn't hide the first one's marker.
Platform Marker storage Consumed
iOS / macOS A state file per launch in Application Support. The crash report arrives on the next launch, so the marker must survive the crash. Previous-launch state is read once per process, the first time either setup() or markFatalExceptionHandled touches it, so an early-startup fatal can't be mistaken for the previous launch. The first matching fatal JS report consumes it.
Android In-process list of the marked messages. JavascriptException is captured in the dying process. The next JavascriptException in beforeSend consumes them all. A leftover marker from a fatal the host recovered from can't hide a later, different crash.
  • Backward compatibility: JS sends errorTracking.fatalExceptionMarker: true in the plugin setup config. Without it (an older JS layer), native keeps the old unconditional drop, so a plugin-only upgrade does not double report. A new JS layer with an older plugin skips the mark (the method is missing), and the old plugin drops as before. On iOS, a crash report from a launch that left no state file (old JS, or an OTA update that applied after the crash) is also dropped the old way.
  • The marker is also set when remote config disables JS exception autocapture. This keeps the current behavior for fatals that JS saw.
  • Behavior change: apps with nativeCrashes on but uncaughtExceptions off now get native reports for JS fatals, because JS captured none of them. The changeset covers this.
  • With console: ['error'] autocapture, a render crash also yields an onconsole error event next to the native fatal. Handler fatals already produce an onconsole event on main.

Device verification

Release builds, events checked in a test project. Each cell is the number of $exception events per crash. "Native" means the plugin's report (RCTFatalException / JavascriptException), "JS" means the SDK's fatal path. Same-uuid pairs at ingestion are counted once.

Setup Render crash Handler crash Error caught by a boundary
RN 0.86, new arch, Expo 57, iOS main: 0 → this PR: 1 native 1 JS, no native duplicate 1 error
RN 0.86, new arch, Expo 57, Android With expo-updates: 0 → 0 (expo-updates takes the fatal, so there is no native crash to report; fixed by #5293). Without expo-updates: 1 native 1 JS, native dropped 1 error
RN 0.88.0-rc.3, new arch, Expo 58, iOS (the ticket's setup) published 4.80.0 + plugin 2.12.5: 0 → this PR: 1 native 1 JS, native dropped
RN 0.79, old arch, bare, Android + iOS main: 0 → this PR: 1 native 1 JS, native dropped 1 error
RN 0.79, old arch, Expo 53 + expo-updates, Android + iOS main: 0 → this PR: 1 native 1 JS, native dropped 1 error
RN 0.76 (React 18), old arch, bare, Android + iOS 1 JS, native dropped 1 JS, native dropped 1 error

Marker edge cases, iOS RN 0.86 unless noted:

  • Early-startup fatal: 1 JS event, no native duplicate. A control run with the marker removed shows the native report does exist.
  • Leftover marker, then a different render crash: the native report is kept.
  • Crashed launch with no state file: dropped the old way.
  • Android RN 0.76: a fatal that bypassed the JS handler was still reported natively after a captured handler fatal.
  • Recovered fatal with an empty (new Error()) or short ('1') message, then a different crash (iOS RN 0.86, Android RN 0.79 and 0.86): the later crash is reported natively.
  • Two fatals with different messages before the crash (iOS RN 0.86, Android RN 0.79 and 0.86): 1 JS event each, native dropped.
  • new Error() and throw {} handler fatals (iOS RN 0.86, Android RN 0.79 and 0.86): 1 JS event, native dropped.

Combined with #5293 (RN 0.79 and 0.76 old arch, Expo old arch): exactly 1 event per crash, and render crashes arrive as JS events with JS frames.

Tests: posthog-react-native vitest, plugin vitest, Kotlin unit tests (26), iOS and Android Release builds. Swift has no test target.

Not covered by device runs:

  • A real upgrade from old JS (simulated by removing the state file).

Related

  • fix(react-native): capture fatal React render errors #5293 captures React 19 render errors from JS on both platforms, with JS frames. It also covers Android apps with expo-updates, where no native crash happens. It goes through the same JS fatal path, so render crashes get marked and the native copy is dropped once both land.
  • Spec check: no sdk-spec covers deduplicating fatals between the JS and native layers (checked capture-exception, exception-event-metadata, exception-steps).

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)

  • Written by Claude Code (PostHog Desktop) from an inbox report.
  • Checks that I ran: posthog-react-native vitest (all pass, 1 snapshot update for the new setup flag), build/typecheck, and lint; @posthog/react-native-plugin vitest, tsc, and lint. Skipped: Android unit tests (no JDK), iOS build (no Xcode).
  • Follow-up commits (marker tied to the error and scoped to the launch, changeset wording, reference regeneration) and the device runs above were directed by a maintainer with Claude Code.
  • How to measure success: after release, the count of iOS $exception events with an RCTFatalException type should go from zero to non-zero. Android JavascriptException events should do the same.

Created with PostHog Desktop from this inbox report.

🤖 Generated with Claude Code

…id not see

React render errors reach ExceptionsManager without the ErrorUtils global handler, so JS never captures them. The native plugins dropped every fatal JS crash report on the assumption that JS already had it, so these crashes were lost.

The JS fatal handler now marks the crash as handled through the plugin before it forwards to React Native. Native drops its fatal JS report only when that marker is set: a file read at the next setup on iOS, an in-process flag on Android. An older JS layer sends no marker flag, so native keeps the old unconditional drop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: 4a1a2a69-9aa2-4278-a49f-7588cc68a55f
…etup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: 4a1a2a69-9aa2-4278-a49f-7588cc68a55f
@posthog

posthog Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor Author

PostHog Review · Deep · reviewed c11ae03

Posted 2 findings: 1 Should fix, 1 Consider.

Step Status Result
Prepare the diff done 8 files, 1 chunk
Pick perspectives done 3 perspectives
Run review passes done 6 issues (+1 blind-spot)
Merge overlapping findings done 7 → 6
Validate findings done 2 kept, 4 dismissed
Publish done 2 posted (view review)

@posthog posthog Bot left a comment

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.

This Deep review started from the posthog-review label. Comment @posthog help to see all commands.

@posthog posthog Bot removed the reviewhog label Oct 13, 2026
@christiaan-ph
christiaan-ph marked this pull request as ready for review October 13, 2026 11:46
@christiaan-ph
christiaan-ph requested a review from a team as a code owner October 13, 2026 11:46
… error

Native drops a fatal JS report only when it carries the marked message, from a launch that used the marker protocol, so early, recovered or pre-upgrade fatals no longer cause losses or duplicates.
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Coherence

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

44 components, 7 bullets (5 invariants, 2 requirements), 4 problems

Spec problems (spec --check)

  • packages/ai/src/otel/Otel.spec.md:7 an invariant bullet starts with <name>: <sentence>
  • packages/ai/src/otel/Otel.spec.md:8 an invariant bullet starts with <name>: <sentence>
  • 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.

@ioannisj ioannisj 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.

Nothing blocking, but a few comments

  • A short or empty fatal key can drop a later, unrelated crash on both platforms once a marked fatal is recovered from, and that crash is usually the render error this PR is meant to report.
  • Apps with uncaughtExceptions: false now get every JS fatal reported natively. Worth confirming that's intended and calling it out in the changeset.
  • PR description needs a sync

guard let previous = previousLaunch else { return true }
previousLaunch = [:]
guard let message = previous[jsFatalMessageKey] as? String else { return false }
return message.isEmpty || texts.contains { $0.contains(message) }

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.

🤖 An AI agent wrote this on behalf of @ioannisj.

[nit] I think a short or empty key can drop an unrelated crash here. isJsHandledFatal drops the report when any of texts contains the marked key, and there's no minimum length, so an empty key (throw {}) or a key like '1' matches nearly anything.

It only bites when the marked fatal doesn't become the crash (a custom or expo-updates fatal handler recovers, or RN reloads the bundle). The marker stays armed, and the next unmarked crash, usually the render error this PR is meant to catch, gets dropped. Android has the same match in dropJsHandledFatalCrash.

On iOS it also crosses launches: JsFatalMarkers.init consumes the previous launch's state on the first touch of .shared, so a fatal marked during startup before setup() replaces launch N's marker, and N's pending report is lost (empty key) or duplicated.

Would it be worth matching against the start of the message instead (after Unhandled JS Exception: on iOS, the start of the JavascriptException value on Android), letting an empty key match only an empty message, and rotating the previous launch's state only from setup?

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.

Good catch, confirmed on devices: a recovered new Error() or '1' marker dropped a later, different crash on both platforms (expo-updates' ErrorRecovery really does recover from startup fatals). Fixed in 2c12c32: JS now marks the <name>: <message> text RN reports, and native matches it at the start of the report's message (after Unhandled JS Exception: on iOS) and requires a shorter marker to end where the message ends. Re-verified on iOS RN 0.86/0.88 and Android RN 0.79/0.86: the later crash is now kept, and new Error() / throw {} fatals still dedupe.

On the cross-launch part: it reproduces, but the result is a duplicate, not a lost report, and it needs two crashes in a row with the second before setup(). Rotating only from setup would bring back the early-startup case fixed in 2fa760c, so I left it.

Comment thread packages/react-native/src/posthog-rn.ts Resolved
…out it

Also declares fatalExceptionMarker on the plugin config type and names the uncaughtExceptions-off case in the changeset.
An empty or short marker no longer drops an unrelated crash, and every fatal marked before the crash is kept.
@turnipdabeets
turnipdabeets added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit b21765a Oct 14, 2026
81 of 82 checks passed
@turnipdabeets
turnipdabeets deleted the posthog-self-driving/fixreact-native-stop-dropping-native-c25d01 branch October 14, 2026 19:21
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.

4 participants