feat(core): Add GlobalErrorBoundary for non-rendering errors#6023
Merged
feat(core): Add GlobalErrorBoundary for non-rendering errors#6023
Conversation
Introduces Sentry.GlobalErrorBoundary (and withGlobalErrorBoundary HOC) that renders a fallback UI for fatal JS errors routed through ErrorUtils, in addition to the render-phase errors caught by Sentry.ErrorBoundary. Opt-in flags includeNonFatalGlobalErrors and includeUnhandledRejections extend coverage to non-fatal global errors and unhandled promise rejections. A small internal pub/sub bus lets reactNativeErrorHandlersIntegration publish errors after capture+flush; when a boundary is subscribed, the integration skips React Native's default fatal handler in release builds so the fallback can own the screen. Dev mode still invokes the default handler for LogBox. Closes #5930
Contributor
Semver Impact of This PR⚪ None (no version bump detected) 📋 Changelog PreviewThis is how your changes will appear in the changelog.
🤖 This preview updates automatically when you update the PR. |
Contributor
|
Move hasInterestedSubscribers() inside the client.flush().then() callback so the decision to skip React Native's default fatal handler reflects subscriber state at the moment flush resolves, not before it. A boundary can mount or unmount during the up-to-2s flush window; using the pre-flush answer could leave the app with neither a fallback UI nor the default handler running. Also updates the CHANGELOG entry to reference the PR instead of the issue (required by Danger), and adds a regression test that simulates a boundary unmounting during flush.
Adds a dedicated screen under the Errors tab in samples/react-native that wraps its content in Sentry.GlobalErrorBoundary and exposes three buttons to trigger each non-rendering error path the component is designed to catch: a synchronous throw from an event handler, a throw inside setTimeout, and an unhandled promise rejection. The fallback renders in-place with a Reset button so the flow can be exercised repeatedly.
Adds assertions in the Hermes, JSC, and React Native Web unhandled promise rejection tests verifying that publishGlobalError is invoked with the correct kind/isFatal payload. Without these, a regression that dropped the bus notification from the rejection paths would silently stop GlobalErrorBoundary from reacting to unhandled rejections.
Contributor
Author
|
@cursor review |
The handlingFatal guard in setupErrorUtilsGlobalHandler was latched on the first fatal and never released. Before GlobalErrorBoundary, this was harmless because the default handler always tore the app down. Now the app can survive the first fatal via the fallback UI, so a persistent latch silently drops every subsequent fatal — no Sentry capture, no bus publish, no fallback refresh. Reset the latch in both the resolve and reject branches of the flush promise so subsequent fatals flow through the full pipeline. Adds a regression test verifying a second fatal is captured and published after the first is handled.
The earlier implementation routed global errors into the upstream @sentry/react ErrorBoundary by re-throwing them from a child component. That produced a duplicate Sentry event: the integration captured the fatal via client.captureEvent before publishing to the bus, and componentDidCatch on the inner boundary then called captureReactException on the same error. Render the fallback directly for the global-error path instead. The upstream ErrorBoundary still handles render-phase errors unchanged. The fallback receives the eventId from lastEventId() — the id of the event captured moments earlier by the integration — so report dialogs and deep links continue to work. onError and onReset are dispatched from the component for both paths. Adds tests covering no-duplicate-capture, eventId propagation, and onError invocation for global errors.
Two related correctness fixes flagged in review: 1. Thread the eventId through the global error bus payload. The boundary previously read lastEventId() at notification time, which is a process-wide singleton — any captureException happening between the integration's capture and the bus delivery would leak the wrong id into the fallback UI. The integration now passes the eventId returned by client.captureEvent / captureException directly, and the boundary prefers it (with lastEventId as a fallback). 2. Isolate subscriber listeners. publishGlobalError now wraps each listener in try/catch and the integration wraps its publish call defensively. A user-supplied onError that throws can no longer unwind into setupErrorUtilsGlobalHandler and leave handlingFatal latched, which would otherwise silently drop every subsequent fatal. Adds tests covering both behaviours: bus eventId beats stale lastEventId; legacy payloads without eventId still fall back; a throwing subscriber doesn't take down siblings or the publisher.
…ertions The fatal-path publish assertion was already using objectContaining to accept the threaded eventId. Normalize the Hermes/JSC and RNW rejection assertions the same way so they remain stable when the bus payload grows new fields.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ca6e7f7. Configure here.
alwx
added a commit
to getsentry/sentry-docs
that referenced
this pull request
Apr 20, 2026
… errors Adds a "Showing a Fallback UI for Fatal Errors" subsection covering Sentry.GlobalErrorBoundary (and the withGlobalErrorBoundary HOC), the includeNonFatalGlobalErrors and includeUnhandledRejections opt-ins, reset semantics, and a migration note replacing the hand-rolled ErrorUtils.setGlobalHandler + DeviceEventEmitter + ErrorBoundary pattern the page previously recommended. Updates the top-of-page warning and the Non-Render Errors intro to link to the new section. The companion SDK change adding the component ships in getsentry/sentry-react-native#6023.
6 tasks
The unconditional reset of handlingFatal in the flush callbacks was asymmetric with where the latch is set (fatal + production only). A concurrent non-fatal's flush resolving between a fatal being latched and its own flush completing would prematurely clear the latch, defeating the dedup guard for a fatal still in flight. Guard the reset with shouldHandleFatal so only the fatal that set the latch can release it.
The default handler is invoked inside a .then() callback attached to the integration's client.flush() promise. Awaiting client.flush() in the test isn't enough to guarantee that continuation has run — different Node versions schedule microtasks differently. Drain with setImmediate before asserting, matching the pattern already used in the re-evaluates-subscribers and releases-latch tests.
Contributor
Android (legacy) Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 7ac3378+dirty | 404.78 ms | 439.84 ms | 35.06 ms |
| 4953e94+dirty | 442.02 ms | 456.52 ms | 14.50 ms |
| 3ce5254+dirty | 410.57 ms | 448.48 ms | 37.91 ms |
| 0d9949d+dirty | 403.57 ms | 437.00 ms | 33.43 ms |
| 5c1e987+dirty | 423.52 ms | 471.64 ms | 48.12 ms |
| 2c735cc+dirty | 414.09 ms | 438.47 ms | 24.38 ms |
| a50b33d+dirty | 500.81 ms | 532.11 ms | 31.30 ms |
| 04207c4+dirty | 459.19 ms | 518.54 ms | 59.35 ms |
| 3817909+dirty | 406.67 ms | 416.58 ms | 9.91 ms |
| df5d108+dirty | 527.06 ms | 603.58 ms | 76.52 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 7ac3378+dirty | 43.75 MiB | 48.13 MiB | 4.37 MiB |
| 4953e94+dirty | 43.75 MiB | 48.08 MiB | 4.33 MiB |
| 3ce5254+dirty | 43.75 MiB | 48.12 MiB | 4.37 MiB |
| 0d9949d+dirty | 43.75 MiB | 48.13 MiB | 4.37 MiB |
| 5c1e987+dirty | 43.75 MiB | 48.08 MiB | 4.33 MiB |
| 2c735cc+dirty | 43.75 MiB | 48.08 MiB | 4.33 MiB |
| a50b33d+dirty | 43.75 MiB | 48.08 MiB | 4.33 MiB |
| 04207c4+dirty | 43.75 MiB | 48.12 MiB | 4.37 MiB |
| 3817909+dirty | 43.75 MiB | 48.08 MiB | 4.33 MiB |
| df5d108+dirty | 43.75 MiB | 48.08 MiB | 4.33 MiB |
📲 Install BuildsAndroid
|
Contributor
iOS (legacy) Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 3817909+dirty | 1183.90 ms | 1187.50 ms | 3.60 ms |
| 3ce5254+dirty | 1219.93 ms | 1221.90 ms | 1.96 ms |
| 04207c4+dirty | 1191.27 ms | 1189.78 ms | -1.48 ms |
| 7ac3378+dirty | 1213.37 ms | 1218.15 ms | 4.78 ms |
| 5c1e987+dirty | 1204.30 ms | 1222.15 ms | 17.85 ms |
| 0d9949d+dirty | 1211.38 ms | 1219.67 ms | 8.29 ms |
| 4953e94+dirty | 1212.06 ms | 1214.83 ms | 2.77 ms |
| df5d108+dirty | 1225.90 ms | 1220.14 ms | -5.76 ms |
| a50b33d+dirty | 1197.74 ms | 1197.17 ms | -0.57 ms |
| 3d377b5+dirty | 1218.48 ms | 1219.51 ms | 1.03 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 3817909+dirty | 3.38 MiB | 4.73 MiB | 1.35 MiB |
| 3ce5254+dirty | 3.38 MiB | 4.76 MiB | 1.38 MiB |
| 04207c4+dirty | 3.38 MiB | 4.76 MiB | 1.38 MiB |
| 7ac3378+dirty | 3.38 MiB | 4.76 MiB | 1.38 MiB |
| 5c1e987+dirty | 3.38 MiB | 4.73 MiB | 1.35 MiB |
| 0d9949d+dirty | 3.38 MiB | 4.76 MiB | 1.38 MiB |
| 4953e94+dirty | 3.38 MiB | 4.73 MiB | 1.35 MiB |
| df5d108+dirty | 3.38 MiB | 4.73 MiB | 1.35 MiB |
| a50b33d+dirty | 3.38 MiB | 4.73 MiB | 1.35 MiB |
| 3d377b5+dirty | 3.38 MiB | 4.76 MiB | 1.38 MiB |
Individual tests created jest.spyOn() for lastEventId, captureReactException, and console.error but only some restored them, leaking mocks into subsequent tests. Centralize in before/afterEach: re-silence console.error per-test and call restoreAllMocks() after each so every inline spy is cleaned up, including those the file didn't explicitly track.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

📢 Type of change
📜 Description
Adds
Sentry.GlobalErrorBoundary(and awithGlobalErrorBoundaryHOC) that renders a fallback UI for fatal JS errors routed throughErrorUtils— event handlers, timers, async code, and other errors thrown outside the React render tree — in addition to the render-phase errors caught by the existingSentry.ErrorBoundary.Two opt-in props extend coverage:
includeNonFatalGlobalErrors— also trigger the fallback on non-fatalErrorUtilserrors.includeUnhandledRejections— also trigger the fallback on unhandled promise rejections.Under the hood, a small internal pub/sub bus lets
reactNativeErrorHandlersIntegrationpublish errors after capture + flush. When a boundary is subscribed, the integration skips React Native's default fatal handler in release builds so the fallback can own the screen. Dev mode still invokes the default handler so LogBox keeps working.💡 Motivation and Context
React error boundaries only catch render-phase errors. Customers who want a fallback UI for fatal errors thrown in event handlers,
setTimeout, promises, or outside the component tree have to overrideErrorUtils.setGlobalHandlerthemselves, disable ouronerrorintegration, and bridge into React state — which is fragile, bypasses our error pipeline (flush, mechanism tagging, fatal dedup), and risks running the app in a corrupted state.Closes #5930.
📝 Checklist
sendDefaultPIIis enabled🔮 Next steps
sentry-docs) with a "Showing a fallback UI for fatal errors" section and a migration note away from hand-rolledsetGlobalHandler.