Repository navigation
Report unhandled JS errors that don't terminate the process as unhandled sessions (not crashed) #6659
Description
Summary
Adopt the native non-terminating session APIs so an unhandled fatal JS error that does not terminate the process is reported with session status unhandled instead of crashed. In practice this is the case where a mounted GlobalErrorBoundary catches a fatal and keeps the app running. Genuine terminating crashes stay crashed, unchanged.
Targeted at the next RN SDK major — not because it is an API/ABI break (it isn't), but because it shifts a visible Release Health metric (crash-free session rate). See Release classification below.
Spec basis
Per the sessions spec:
crashed— "session terminated in a crash (process terminated)"unhandled(spec v1.6.0) — "an unhandled error occurred but the process did not terminate", e.g. "the language or framework prevented termination"
GlobalErrorBoundary is exactly that framework mechanism, so a survived fatal is an unhandled session by definition. Reporting it as crashed is a spec deviation. (sentry-conventions is span/attribute naming only — neutral here.)
Current RN behavior & the confirmed over-report
RN has no dedicated session API over the bridge; a hard crash is signaled by the hardCrashed boolean on captureEnvelope:
hardCrashedis set only for mechanism{ handled: false, type: 'onerror' }—packages/core/src/js/misc.ts:11, computed inpackages/core/src/js/wrapper.ts:196-235.- Android maps it directly:
InternalSentrySdk.captureEnvelope(bytes, !hardCrashed)—RNSentryModuleImpl.java:500-511. WhenhardCrashed,maybeStartNewSession=falseand the current session ends ascrashed. - iOS:
hardCrashed→store(persist for next launch), elsecapture—RNSentry.mm:624-646.
reactnativeerrorhandlers.ts sets { handled: false, type: 'onerror' } for all isFatal errors. But when a GlobalErrorBoundary fallback is mounted, the handler deliberately skips defaultHandler (reactnativeerrorhandlers.ts:242-244) so the app survives — yet the session is still reported crashed. That is the over-report, and it is a gap the SDK created itself by shipping GlobalErrorBoundary.
Unhandled promise rejections are already marked handled: true and are unaffected.
Native APIs (available now, both platforms bundled)
- Android — sentry-java 8.55.0 (#5921), merged via chore(deps): update Android SDK to v8.55.0 #6658:
InternalSentrySdk.captureEnvelopeNonTerminating(byte[]),Session.State.Unhandled. - iOS — sentry-cocoa 9.27.0 (already pinned in
RNSentry.podspec):SentrySDK.internal.envelope.captureNonTerminating(_:). - Both: keep the same session id, bump the error count, mark the session
unhandled, and a later crash or abnormal exit still takes precedence overunhandled(built-in safety net). Never also callupdateSessionForDroppedEventNonTerminatingfor the same event (double-count).
Flutter precedent — and why RN diverges
Flutter is adopting the same native APIs as a stacked series (fixes sentry-dart#3300), all opened 2026-09-07:
- #4006 — bump native SDKs (Android 8.55.0 + Cocoa 9.27.0).
- #4007
feat— capture path viacaptureEnvelopeNonTerminating/captureNonTerminating. - #4008
fix— sampling path viaupdateSessionForDroppedEventNonTerminatingfor sampled-out unhandled events.
Both Flutter PRs are marked "No breaking changes"; #3300 is labeled Improvement. The work is grouped into Flutter's v10 for release-timing reasons (a visible metric change on a major boundary), not because the code breaks compatibility.
Key divergence: Flutter pushes the decision fully into native by inspecting mechanism.handled == false, because in Flutter an unhandled framework error never terminates the process. RN is different — an unhandled onerror fatal does terminate (RCTFatal) unless a GlobalErrorBoundary catches it. So RN must not copy Flutter's native-decides model (it would mislabel real JS crashes as unhandled and lose crash reporting). RN keeps the survival signal in JS and carries it across the bridge.
Proposed design
| # | Module | Change |
|---|---|---|
| M1 | integrations/reactnativeerrorhandlers.ts |
Owns the survival decision. When isFatal and an interested GlobalErrorBoundary subscriber is present (the path where defaultHandler is skipped), annotate the event's exception mechanism as non-terminating. Every other fatal stays crashed. |
| M2 | misc.ts |
Reshape isHardCrash(payload) → getSessionDisposition(payload): 'crashed' | 'unhandled' | 'ok', read from the serialized envelope payload (the only channel to the wrapper). |
| M3 | NativeRNSentry.ts + wrapper.ts |
Additive nonTerminating: boolean on captureEnvelope options, beside hardCrashed (mutually exclusive). Additive key on an untyped Object param → backward-compatible ABI; an older cached native binary ignores it and degrades to a normal capture. |
| M4 | RNSentryModuleImpl.java |
nonTerminating → InternalSentrySdk.captureEnvelopeNonTerminating(bytes); else the existing path. Runs off-main (@ReactMethod), safe for the API's synchronous persist. |
| M5 | RNSentry.mm + RNSentryInternal.swift |
Add RNSentryInternal.captureNonTerminating(_:) → SentrySDK.internal.envelope.captureNonTerminating(envelope); branch on nonTerminating before the store/capture fork. |
Phasing
- Phase 1 — capture path (this issue): M1–M5. Mirrors Flutter chore(deps): update Cocoa SDK to v8.33.0 #4007; closes the
GlobalErrorBoundaryover-report. - Phase 2 — sampling path (follow-up): unhandled events dropped by
sampleRatenever reachcaptureEnvelope, so their session would finalizeexitedinstead ofunhandled. Needs a JS-core sampled-out hook + a new bridge methodupdateSessionForDroppedEventNonTerminating, with a no-double-count guard. Mirrors Flutter chore(deps): bump rexml from 3.2.6 to 3.3.3 in /samples/react-native #4008 — hold until that design settles.
Release classification
- Not an API/ABI break. No public JS API change; the bridge change is additive and backward-compatible.
- It is a Release Health behavior shift —
GlobalErrorBoundary-survived errors move fromcrashed→unhandled, raising crash-free session rate for affected apps. Ship in the next major, with a prominent CHANGELOG entry and a docs note so users understand the metric change.
Open risk
M1's survival check is re-evaluated post-flush, but the envelope is captured pre-flush → the disposition is a best-effort prediction. Worst case is a rare crashed↔unhandled mislabel, self-corrected by the "later crash takes precedence" rule. Never a host-app crash.
Follow-up from the 8.55.0 bump (#6658).
Activity
linear-code commentedon Sep 3, 2026
RN-755
[-]Evaluate non-terminating session state for unhandled JS errors (captureEnvelopeNonTerminating / Session.State.Unhandled)[/-][+]Report unhandled JS errors that don't terminate the process as `unhandled` sessions (not `crashed`)[/+]