Skip to content

feat(core): Add GlobalErrorBoundary for non-rendering errors#6023

Merged
alwx merged 15 commits intomainfrom
alwx/feature/5930
Apr 20, 2026
Merged

feat(core): Add GlobalErrorBoundary for non-rendering errors#6023
alwx merged 15 commits intomainfrom
alwx/feature/5930

Conversation

@alwx
Copy link
Copy Markdown
Contributor

@alwx alwx commented Apr 20, 2026

📢 Type of change

  • Bugfix
  • New feature
  • Enhancement
  • Refactoring

📜 Description

Adds Sentry.GlobalErrorBoundary (and a withGlobalErrorBoundary HOC) that renders a fallback UI for fatal JS errors routed through ErrorUtils — event handlers, timers, async code, and other errors thrown outside the React render tree — in addition to the render-phase errors caught by the existing Sentry.ErrorBoundary.

<Sentry.GlobalErrorBoundary
  fallback={({ error, resetError }) => <MyFallback error={error} onRetry={resetError} />}
>
  <App />
</Sentry.GlobalErrorBoundary>

Two opt-in props extend coverage:

  • includeNonFatalGlobalErrors — also trigger the fallback on non-fatal ErrorUtils errors.
  • includeUnhandledRejections — also trigger the fallback on unhandled promise rejections.

Under the hood, 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 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 override ErrorUtils.setGlobalHandler themselves, disable our onerror integration, 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

  • I added tests to verify changes
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • All tests passing
  • No breaking changes

🔮 Next steps

  • Update the public docs (sentry-docs) with a "Showing a fallback UI for fatal errors" section and a migration note away from hand-rolled setGlobalHandler.

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
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 20, 2026

Semver Impact of This PR

None (no version bump detected)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


  • feat(core): Add GlobalErrorBoundary for non-rendering errors by alwx in #6023
  • chore(deps): update CLI to v3.4.0 by github-actions in #6026
  • feat: Expose screenshot masking options for error screenshots by antonis in #6007
  • fix(replay): Check captureReplay return value in iOS bridge by antonis in #6008
  • chore(deps): bump getsentry/craft from 2.25.2 to 2.25.4 by dependabot in #6019
  • chore(deps): bump getsentry/craft/.github/workflows/changelog-preview.yml from 2.25.2 to 2.25.4 by dependabot in #6021
  • chore(deps): bump github/codeql-action from 4.35.1 to 4.35.2 by dependabot in #6022
  • chore(deps): bump actions/setup-node from 6.3.0 to 6.4.0 by dependabot in #6020
  • ci(danger): Demote Android SDK version mismatch from fail to warn by antonis in #6018
  • chore(deps): update Android SDK to v8.39.1 by github-actions in #6010
  • chore(deps): update JavaScript SDK to v10.49.0 by github-actions in #6011
  • ci: Integrate Warden for AI-powered PR code review by antonis in #6003
  • chore(lint): Fixes lint issue on main by antonis in #6013
  • feat(expo): Warn when prebuilt native projects are missing Sentry config by alwx in #5984

🤖 This preview updates automatically when you update the PR.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 20, 2026

Messages
📖 Do not forget to update Sentry-docs with your feature once the pull request gets approved.

Generated by 🚫 dangerJS against 04fa8d1

Comment thread packages/core/src/js/integrations/reactnativeerrorhandlers.ts Outdated
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.
Comment thread packages/core/src/js/integrations/reactnativeerrorhandlers.ts Outdated
alwx added 3 commits April 20, 2026 11:40
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.
@alwx
Copy link
Copy Markdown
Contributor Author

alwx commented Apr 20, 2026

@cursor review

Comment thread packages/core/test/integrations/reactnativeerrorhandlers.test.ts
Comment thread packages/core/src/js/integrations/reactnativeerrorhandlers.ts
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.
@alwx alwx marked this pull request as ready for review April 20, 2026 11:05
Comment thread packages/core/src/js/GlobalErrorBoundary.tsx
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.
Comment thread packages/core/src/js/GlobalErrorBoundary.tsx
Comment thread packages/core/src/js/integrations/reactnativeerrorhandlers.ts Outdated
@alwx alwx self-assigned this Apr 20, 2026
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.
Comment thread packages/core/test/integrations/reactnativeerrorhandlers.test.ts Outdated
…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.
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread packages/core/src/js/integrations/reactnativeerrorhandlers.ts Outdated
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.
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.
@antonis antonis added the ready-to-merge Triggers the full CI test suite label Apr 20, 2026
Comment thread packages/core/test/integrations/reactnativeerrorhandlers.test.ts
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.
Copy link
Copy Markdown
Contributor

@antonis antonis left a comment

Choose a reason for hiding this comment

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

Other than the Warden finding which is on the testing side LGTM 🚀

I went ahead and added the ready-to-merge label. Let's leave the full test suite to run since this is a big PR 😅

Comment thread packages/core/test/GlobalErrorBoundary.test.tsx
@github-actions
Copy link
Copy Markdown
Contributor

Android (legacy) Performance metrics 🚀

  Plain With Sentry Diff
Startup time 396.39 ms 425.30 ms 28.91 ms
Size 43.75 MiB 48.14 MiB 4.39 MiB

Baseline results on branch: main

Startup times

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

@sentry
Copy link
Copy Markdown

sentry bot commented Apr 20, 2026

📲 Install Builds

Android

🔗 App Name App ID Version Configuration
Sentry RN io.sentry.reactnative.sample 8.8.0 (83) Release

⚙️ sentry-react-native Build Distribution Settings

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 20, 2026

iOS (legacy) Performance metrics 🚀

  Plain With Sentry Diff
Startup time 1213.73 ms 1214.19 ms 0.46 ms
Size 3.38 MiB 4.77 MiB 1.39 MiB

Baseline results on branch: main

Startup times

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.
@alwx alwx merged commit 5fe1c6c into main Apr 20, 2026
76 of 84 checks passed
@alwx alwx deleted the alwx/feature/5930 branch April 20, 2026 14:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-merge Triggers the full CI test suite

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support fallback UI for non-rendering JS errors (GlobalErrorBoundary)

2 participants