From f7fff1d533f73da53ad1c65c2b12a77153b4e7f2 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 20 Apr 2026 10:50:55 +0200 Subject: [PATCH 01/12] feat(core): Add GlobalErrorBoundary for non-rendering errors 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 --- CHANGELOG.md | 1 + packages/core/src/js/GlobalErrorBoundary.tsx | 177 ++++++++++++++ packages/core/src/js/index.ts | 2 + .../src/js/integrations/globalErrorBus.ts | 109 +++++++++ .../integrations/reactnativeerrorhandlers.ts | 20 +- .../core/test/GlobalErrorBoundary.test.tsx | 224 ++++++++++++++++++ .../reactnativeerrorhandlers.test.ts | 59 +++++ 7 files changed, 590 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/js/GlobalErrorBoundary.tsx create mode 100644 packages/core/src/js/integrations/globalErrorBus.ts create mode 100644 packages/core/test/GlobalErrorBoundary.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index d177a44677..55357207c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Features - Warn Expo users at Metro startup when prebuilt native projects are missing Sentry configuration ([#5984](https://github.com/getsentry/sentry-react-native/pull/5984)) +- Add `Sentry.GlobalErrorBoundary` component (and `withGlobalErrorBoundary` HOC) that renders a fallback UI for fatal non-rendering JS errors routed through `ErrorUtils` in addition to the render-phase errors caught by `Sentry.ErrorBoundary`. Opt-in flags `includeNonFatalGlobalErrors` and `includeUnhandledRejections` extend the fallback to non-fatal errors and unhandled promise rejections respectively. ([#5930](https://github.com/getsentry/sentry-react-native/issues/5930)) ### Dependencies diff --git a/packages/core/src/js/GlobalErrorBoundary.tsx b/packages/core/src/js/GlobalErrorBoundary.tsx new file mode 100644 index 0000000000..fa3f4fcf50 --- /dev/null +++ b/packages/core/src/js/GlobalErrorBoundary.tsx @@ -0,0 +1,177 @@ +import type { ErrorBoundaryProps } from '@sentry/react'; + +import { ErrorBoundary } from '@sentry/react'; +import * as React from 'react'; + +import type { GlobalErrorEvent } from './integrations/globalErrorBus'; + +import { subscribeGlobalError } from './integrations/globalErrorBus'; + +/** + * Props for {@link GlobalErrorBoundary}. Extends the standard `ErrorBoundary` + * props from `@sentry/react` with two opt-ins that control which + * non-rendering errors trigger the fallback UI. + */ +export type GlobalErrorBoundaryProps = ErrorBoundaryProps & { + /** + * If `true`, the fallback is also rendered for *non-fatal* errors routed + * through `ErrorUtils` (React Native's global handler). + * + * Defaults to `false` — only fatals trigger the fallback, matching the + * semantics of the native red-screen. + */ + includeNonFatalGlobalErrors?: boolean; + + /** + * If `true`, the fallback is also rendered for unhandled promise rejections. + * + * Defaults to `false` because many apps prefer to surface rejections as + * toasts / inline errors rather than as a full-screen fallback. + */ + includeUnhandledRejections?: boolean; +}; + +interface GlobalErrorThrowerProps { + error: unknown | null; + children?: React.ReactNode | (() => React.ReactNode); +} + +/** + * Tiny component that re-throws a global error during render so the + * surrounding `ErrorBoundary` catches it through the standard React path. + */ +class GlobalErrorThrower extends React.Component { + public render(): React.ReactNode { + if (this.props.error !== null && this.props.error !== undefined) { + // Throwing here routes the error into the surrounding ErrorBoundary's + // getDerivedStateFromError / componentDidCatch lifecycle. + throw this.props.error; + } + return typeof this.props.children === 'function' ? this.props.children() : this.props.children; + } +} + +interface GlobalErrorBoundaryState { + globalError: unknown | null; +} + +/** + * An error boundary that also catches **non-rendering** fatal JS errors. + * + * In addition to the render-phase errors caught by `Sentry.ErrorBoundary`, + * this component renders the provided fallback when: + * + * - A fatal error is reported through React Native's `ErrorUtils` global + * handler (event handlers, timers, native → JS bridge errors, …). + * - Optionally, non-fatal global errors (opt-in via + * `includeNonFatalGlobalErrors`). + * - Optionally, unhandled promise rejections (opt-in via + * `includeUnhandledRejections`). + * + * The Sentry error pipeline (capture → flush → mechanism tagging) is + * unchanged; this component only surfaces the fallback UI and suppresses + * React Native's default fatal handler while the fallback is mounted. + * + * Intended usage is at the top of the component tree, typically just inside + * `Sentry.wrap()`: + * + * ```tsx + * ( + * + * )} + * > + * + * + * ``` + */ +export class GlobalErrorBoundary extends React.Component { + public state: GlobalErrorBoundaryState = { globalError: null }; + + private _unsubscribe?: () => void; + private _latched = false; + + public componentDidMount(): void { + this._unsubscribe = subscribeGlobalError(this._onGlobalError, { + fatal: true, + nonFatal: !!this.props.includeNonFatalGlobalErrors, + unhandledRejection: !!this.props.includeUnhandledRejections, + }); + } + + public componentWillUnmount(): void { + this._unsubscribe?.(); + this._unsubscribe = undefined; + } + + public componentDidUpdate(prevProps: GlobalErrorBoundaryProps): void { + // Re-subscribe if the opt-in flags change so the filter stays accurate. + if ( + prevProps.includeNonFatalGlobalErrors !== this.props.includeNonFatalGlobalErrors || + prevProps.includeUnhandledRejections !== this.props.includeUnhandledRejections + ) { + this._unsubscribe?.(); + this._unsubscribe = subscribeGlobalError(this._onGlobalError, { + fatal: true, + nonFatal: !!this.props.includeNonFatalGlobalErrors, + unhandledRejection: !!this.props.includeUnhandledRejections, + }); + } + } + + public render(): React.ReactNode { + const { + children, + onReset, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + includeNonFatalGlobalErrors: _ignoredA, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + includeUnhandledRejections: _ignoredB, + ...forwarded + } = this.props; + + return ( + + {children} + + ); + } + + private _onGlobalError = (event: GlobalErrorEvent): void => { + // Keep the first error — once the fallback is up, subsequent errors + // shouldn't rewrite what the user is looking at. We use an instance flag + // instead of reading state because multiple publishes can fire in the + // same batch, before setState has flushed. + if (this._latched) return; + this._latched = true; + this.setState({ globalError: event.error ?? new Error('Unknown global error') }); + }; + + private _onReset = + (userOnReset: GlobalErrorBoundaryProps['onReset']) => + (error: unknown, componentStack: string, eventId: string): void => { + this._latched = false; + this.setState({ globalError: null }); + userOnReset?.(error, componentStack, eventId); + }; +} + +/** + * HOC counterpart to {@link GlobalErrorBoundary}. + */ +export function withGlobalErrorBoundary

>( + WrappedComponent: React.ComponentType

, + errorBoundaryOptions: GlobalErrorBoundaryProps, +): React.FC

{ + const componentDisplayName = WrappedComponent.displayName || WrappedComponent.name || 'unknown'; + + const Wrapped: React.FC

= props => ( + + + + ); + + Wrapped.displayName = `globalErrorBoundary(${componentDisplayName})`; + + return Wrapped; +} diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index 570f8180eb..251fed0c30 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -75,6 +75,8 @@ export { ReactNativeClient } from './client'; export { init, wrap, nativeCrash, flush, close, withScope, crashedLastRun, appLoaded } from './sdk'; export { TouchEventBoundary, withTouchEventBoundary } from './touchevents'; +export { GlobalErrorBoundary, withGlobalErrorBoundary } from './GlobalErrorBoundary'; +export type { GlobalErrorBoundaryProps } from './GlobalErrorBoundary'; export { reactNativeTracingIntegration, diff --git a/packages/core/src/js/integrations/globalErrorBus.ts b/packages/core/src/js/integrations/globalErrorBus.ts new file mode 100644 index 0000000000..00ddc33490 --- /dev/null +++ b/packages/core/src/js/integrations/globalErrorBus.ts @@ -0,0 +1,109 @@ +/** + * Global error bus used by {@link GlobalErrorBoundary} to receive errors that + * are captured outside the React render tree (e.g. `ErrorUtils` fatals, + * unhandled promise rejections). + * + * The bus is intentionally tiny and stored on the global object so that it + * survives Fast Refresh during development. + * + * This module is internal to the SDK. + */ + +import { RN_GLOBAL_OBJ } from '../utils/worldwide'; + +/** Where the error came from. */ +export type GlobalErrorKind = 'onerror' | 'onunhandledrejection'; + +/** Payload delivered to subscribers. */ +export interface GlobalErrorEvent { + error: unknown; + isFatal: boolean; + kind: GlobalErrorKind; +} + +/** Options describing which kinds of errors a subscriber wants. */ +export interface GlobalErrorSubscriberOptions { + /** Receive fatal `ErrorUtils` errors. Defaults to true. */ + fatal?: boolean; + /** Receive non-fatal `ErrorUtils` errors. Defaults to false. */ + nonFatal?: boolean; + /** Receive unhandled promise rejections. Defaults to false. */ + unhandledRejection?: boolean; +} + +type Listener = (event: GlobalErrorEvent) => void; + +interface Subscriber { + listener: Listener; + options: Required; +} + +interface BusState { + subscribers: Set; +} + +interface GlobalWithBus { + __SENTRY_RN_GLOBAL_ERROR_BUS__?: BusState; +} + +function getBus(): BusState { + const host = RN_GLOBAL_OBJ as unknown as GlobalWithBus; + if (!host.__SENTRY_RN_GLOBAL_ERROR_BUS__) { + host.__SENTRY_RN_GLOBAL_ERROR_BUS__ = { subscribers: new Set() }; + } + return host.__SENTRY_RN_GLOBAL_ERROR_BUS__; +} + +/** + * Subscribe to global errors. Returns an unsubscribe function. + */ +export function subscribeGlobalError(listener: Listener, options: GlobalErrorSubscriberOptions = {}): () => void { + const subscriber: Subscriber = { + listener, + options: { + fatal: options.fatal ?? true, + nonFatal: options.nonFatal ?? false, + unhandledRejection: options.unhandledRejection ?? false, + }, + }; + getBus().subscribers.add(subscriber); + return () => { + getBus().subscribers.delete(subscriber); + }; +} + +/** + * Returns true if at least one subscriber is interested in the given event. + * + * Used by the error handlers integration to decide whether to skip invoking + * React Native's default error handler (which would otherwise tear down the + * JS context and prevent any fallback UI from rendering). + */ +export function hasInterestedSubscribers(kind: GlobalErrorKind, isFatal: boolean): boolean { + for (const { options } of getBus().subscribers) { + if (kind === 'onerror') { + if (isFatal ? options.fatal : options.nonFatal) return true; + } else if (kind === 'onunhandledrejection' && options.unhandledRejection) { + return true; + } + } + return false; +} + +/** + * Publish a global error to all interested subscribers. + */ +export function publishGlobalError(event: GlobalErrorEvent): void { + for (const { listener, options } of getBus().subscribers) { + if (event.kind === 'onerror') { + if (event.isFatal ? options.fatal : options.nonFatal) listener(event); + } else if (event.kind === 'onunhandledrejection' && options.unhandledRejection) { + listener(event); + } + } +} + +/** Test-only: clear all subscribers. */ +export function _resetGlobalErrorBus(): void { + getBus().subscribers.clear(); +} diff --git a/packages/core/src/js/integrations/reactnativeerrorhandlers.ts b/packages/core/src/js/integrations/reactnativeerrorhandlers.ts index 35ff650ae3..c8e0ce3b0e 100644 --- a/packages/core/src/js/integrations/reactnativeerrorhandlers.ts +++ b/packages/core/src/js/integrations/reactnativeerrorhandlers.ts @@ -14,6 +14,7 @@ import type { ReactNativeClientOptions } from '../options'; import { isHermesEnabled, isWeb } from '../utils/environment'; import { createSyntheticError, isErrorLike } from '../utils/error'; import { RN_GLOBAL_OBJ } from '../utils/worldwide'; +import { hasInterestedSubscribers, publishGlobalError } from './globalErrorBus'; import { checkPromiseAndWarn, polyfillPromise, requireRejectionTracking } from './reactnativeerrorhandlersutils'; const INTEGRATION_NAME = 'ReactNativeErrorHandlers'; @@ -80,6 +81,7 @@ function setupUnhandledRejectionsTracking(patchGlobalPromise: boolean): void { syntheticException: isErrorLike(error) ? undefined : createSyntheticError(), mechanism: { handled: false, type: 'onunhandledrejection' }, }); + publishGlobalError({ error, isFatal: false, kind: 'onunhandledrejection' }); }); } else if (patchGlobalPromise) { // For JSC and other environments, use the existing approach @@ -113,6 +115,7 @@ const promiseRejectionTrackingOptions: PromiseRejectionTrackingOptions = { syntheticException: isErrorLike(error) ? undefined : createSyntheticError(), mechanism: { handled: true, type: 'onunhandledrejection' }, }); + publishGlobalError({ error, isFatal: false, kind: 'onunhandledrejection' }); }, onHandled: id => { if (__DEV__) { @@ -204,16 +207,29 @@ function setupErrorUtilsGlobalHandler(): void { client.captureEvent(event, hint); + // Notify any mounted GlobalErrorBoundary. Subscribers filter internally by + // fatal/non-fatal preferences. + publishGlobalError({ error, isFatal: !!isFatal, kind: 'onerror' }); + + // If a GlobalErrorBoundary is interested in this error, we skip the + // default handler so the fallback UI can own the screen. Otherwise the + // default handler would unmount React (in release) or show LogBox (in dev) + // over our fallback. + const fallbackWillRender = hasInterestedSubscribers('onerror', !!isFatal); + if (__DEV__) { // If in dev, we call the default handler anyway and hope the error will be sent - // Just for a better dev experience + // Just for a better dev experience. If a fallback is mounted it will still + // render alongside LogBox. defaultHandler(error, isFatal); return; } void client.flush((client.getOptions() as ReactNativeClientOptions).shutdownTimeout || 2000).then( () => { - defaultHandler(error, isFatal); + if (!fallbackWillRender) { + defaultHandler(error, isFatal); + } }, (reason: unknown) => { debug.error('[ReactNativeErrorHandlers] Error while flushing the event cache after uncaught error.', reason); diff --git a/packages/core/test/GlobalErrorBoundary.test.tsx b/packages/core/test/GlobalErrorBoundary.test.tsx new file mode 100644 index 0000000000..f1cf3bc824 --- /dev/null +++ b/packages/core/test/GlobalErrorBoundary.test.tsx @@ -0,0 +1,224 @@ +import { act, fireEvent, render } from '@testing-library/react-native'; +import * as React from 'react'; +import { Text, View } from 'react-native'; + +import { GlobalErrorBoundary, withGlobalErrorBoundary } from '../src/js/GlobalErrorBoundary'; +import { + _resetGlobalErrorBus, + hasInterestedSubscribers, + publishGlobalError, +} from '../src/js/integrations/globalErrorBus'; + +function Fallback({ error, resetError }: { error: unknown; resetError: () => void }): React.ReactElement { + return ( + + fallback:{(error as Error)?.message ?? 'none'} + + reset + + + ); +} + +function Ok(): React.ReactElement { + return ok; +} + +describe('GlobalErrorBoundary', () => { + // react-test-renderer / React surfaces a console.error for uncaught exceptions + // routed through componentDidCatch. Silence to keep test output clean. + let errorSpy: jest.SpyInstance; + beforeAll(() => { + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + afterAll(() => { + errorSpy.mockRestore(); + }); + + beforeEach(() => { + _resetGlobalErrorBus(); + }); + + test('renders children when no error occurs', () => { + const { queryByTestId } = render( + }> + + , + ); + + expect(queryByTestId('ok')).not.toBeNull(); + expect(queryByTestId('fallback')).toBeNull(); + }); + + test('renders fallback on a render-phase error (delegates to upstream ErrorBoundary)', () => { + function Boom(): React.ReactElement { + throw new Error('render-boom'); + } + + const { getByTestId } = render( + }> + + , + ); + + expect(getByTestId('fallback').props.children.join('')).toBe('fallback:render-boom'); + }); + + test('renders fallback when a fatal global error is published', () => { + const { getByTestId, queryByTestId } = render( + }> + + , + ); + + act(() => { + publishGlobalError({ error: new Error('global-boom'), isFatal: true, kind: 'onerror' }); + }); + + expect(getByTestId('fallback').props.children.join('')).toBe('fallback:global-boom'); + expect(queryByTestId('ok')).toBeNull(); + }); + + test('ignores non-fatal global errors by default', () => { + const { queryByTestId } = render( + }> + + , + ); + + act(() => { + publishGlobalError({ error: new Error('soft'), isFatal: false, kind: 'onerror' }); + }); + + expect(queryByTestId('ok')).not.toBeNull(); + expect(queryByTestId('fallback')).toBeNull(); + }); + + test('renders fallback for non-fatal global errors when opted in', () => { + const { getByTestId } = render( + } includeNonFatalGlobalErrors> + + , + ); + + act(() => { + publishGlobalError({ error: new Error('soft'), isFatal: false, kind: 'onerror' }); + }); + + expect(getByTestId('fallback').props.children.join('')).toBe('fallback:soft'); + }); + + test('ignores unhandled promise rejections by default', () => { + const { queryByTestId } = render( + }> + + , + ); + + act(() => { + publishGlobalError({ error: new Error('rej'), isFatal: false, kind: 'onunhandledrejection' }); + }); + + expect(queryByTestId('fallback')).toBeNull(); + }); + + test('renders fallback for unhandled rejections when opted in', () => { + const { getByTestId } = render( + } includeUnhandledRejections> + + , + ); + + act(() => { + publishGlobalError({ error: new Error('rej'), isFatal: false, kind: 'onunhandledrejection' }); + }); + + expect(getByTestId('fallback').props.children.join('')).toBe('fallback:rej'); + }); + + test('resetError restores children and clears internal state', () => { + const onReset = jest.fn(); + const { getByTestId, queryByTestId } = render( + } onReset={onReset}> + + , + ); + + act(() => { + publishGlobalError({ error: new Error('first'), isFatal: true, kind: 'onerror' }); + }); + expect(queryByTestId('fallback')).not.toBeNull(); + + fireEvent.press(getByTestId('reset')); + + expect(queryByTestId('ok')).not.toBeNull(); + expect(queryByTestId('fallback')).toBeNull(); + expect(onReset).toHaveBeenCalledTimes(1); + + act(() => { + publishGlobalError({ error: new Error('second'), isFatal: true, kind: 'onerror' }); + }); + expect(getByTestId('fallback').props.children.join('')).toBe('fallback:second'); + }); + + test('first global error wins while fallback is mounted', () => { + const { getByTestId } = render( + }> + + , + ); + + act(() => { + publishGlobalError({ error: new Error('first'), isFatal: true, kind: 'onerror' }); + publishGlobalError({ error: new Error('second'), isFatal: true, kind: 'onerror' }); + }); + + expect(getByTestId('fallback').props.children.join('')).toBe('fallback:first'); + }); + + test('unsubscribes on unmount', () => { + const { unmount } = render( + }> + + , + ); + + expect(hasInterestedSubscribers('onerror', true)).toBe(true); + unmount(); + expect(hasInterestedSubscribers('onerror', true)).toBe(false); + }); + + test('withGlobalErrorBoundary wraps a component', () => { + const Wrapped = withGlobalErrorBoundary(Ok, { fallback: props => }); + const { getByTestId, queryByTestId } = render(); + + expect(queryByTestId('ok')).not.toBeNull(); + + act(() => { + publishGlobalError({ error: new Error('hoc-boom'), isFatal: true, kind: 'onerror' }); + }); + expect(getByTestId('fallback').props.children.join('')).toBe('fallback:hoc-boom'); + }); +}); + +describe('hasInterestedSubscribers', () => { + beforeEach(() => _resetGlobalErrorBus()); + + test('returns false when no subscribers exist', () => { + expect(hasInterestedSubscribers('onerror', true)).toBe(false); + expect(hasInterestedSubscribers('onerror', false)).toBe(false); + expect(hasInterestedSubscribers('onunhandledrejection', false)).toBe(false); + }); + + test('respects subscriber opt-ins', () => { + render( + fb} includeUnhandledRejections> + x + , + ); + + expect(hasInterestedSubscribers('onerror', true)).toBe(true); + expect(hasInterestedSubscribers('onerror', false)).toBe(false); + expect(hasInterestedSubscribers('onunhandledrejection', false)).toBe(true); + }); +}); diff --git a/packages/core/test/integrations/reactnativeerrorhandlers.test.ts b/packages/core/test/integrations/reactnativeerrorhandlers.test.ts index 4611a6425b..895eb347ba 100644 --- a/packages/core/test/integrations/reactnativeerrorhandlers.test.ts +++ b/packages/core/test/integrations/reactnativeerrorhandlers.test.ts @@ -5,6 +5,7 @@ import type { SeverityLevel } from '@sentry/core'; import { addGlobalUnhandledRejectionInstrumentationHandler, captureException, setCurrentClient } from '@sentry/core'; +import * as globalErrorBus from '../../src/js/integrations/globalErrorBus'; import { reactNativeErrorHandlersIntegration } from '../../src/js/integrations/reactnativeerrorhandlers'; import { checkPromiseAndWarn, @@ -196,6 +197,60 @@ describe('ReactNativeErrorHandlers', () => { expect(error.stack).toBe(originalStack); }); + + describe('GlobalErrorBoundary integration', () => { + let publishSpy: jest.SpyInstance; + let hasSubscribersSpy: jest.SpyInstance; + let defaultHandler: jest.Mock; + + beforeEach(() => { + publishSpy = jest.spyOn(globalErrorBus, 'publishGlobalError').mockImplementation(() => {}); + hasSubscribersSpy = jest.spyOn(globalErrorBus, 'hasInterestedSubscribers'); + defaultHandler = jest.fn(); + (RN_GLOBAL_OBJ.ErrorUtils!.getGlobalHandler as jest.Mock).mockReturnValue(defaultHandler); + set__DEV__(false); + }); + + afterEach(() => { + publishSpy.mockRestore(); + hasSubscribersSpy.mockRestore(); + set__DEV__(true); + }); + + test('publishes fatals to the global error bus', async () => { + hasSubscribersSpy.mockReturnValue(false); + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce!(); + + const error = new Error('Boom'); + await errorHandlerCallback!(error, true); + await client.flush(); + + expect(publishSpy).toHaveBeenCalledWith({ error, isFatal: true, kind: 'onerror' }); + }); + + test('still invokes the default handler when no boundary is subscribed', async () => { + hasSubscribersSpy.mockReturnValue(false); + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce!(); + + await errorHandlerCallback!(new Error('Boom'), true); + await client.flush(); + + expect(defaultHandler).toHaveBeenCalledTimes(1); + }); + + test('skips the default handler on fatals when a boundary is subscribed', async () => { + hasSubscribersSpy.mockImplementation((kind, isFatal) => kind === 'onerror' && isFatal === true); + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce!(); + + await errorHandlerCallback!(new Error('Boom'), true); + await client.flush(); + + expect(defaultHandler).not.toHaveBeenCalled(); + }); + }); }); describe('onUnhandledRejection', () => { @@ -391,3 +446,7 @@ describe('ReactNativeErrorHandlers', () => { }); }); }); + +function set__DEV__(value: boolean): void { + Object.defineProperty(globalThis, '__DEV__', { value, writable: true }); +} From dd124903e7059991ba890766f12c7a8e52697dda Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 20 Apr 2026 11:24:32 +0200 Subject: [PATCH 02/12] fix(core): Re-check GlobalErrorBoundary subscribers after flush 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. --- CHANGELOG.md | 2 +- .../js/integrations/reactnativeerrorhandlers.ts | 13 ++++++------- .../reactnativeerrorhandlers.test.ts | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55357207c7..82068367fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ### Features - Warn Expo users at Metro startup when prebuilt native projects are missing Sentry configuration ([#5984](https://github.com/getsentry/sentry-react-native/pull/5984)) -- Add `Sentry.GlobalErrorBoundary` component (and `withGlobalErrorBoundary` HOC) that renders a fallback UI for fatal non-rendering JS errors routed through `ErrorUtils` in addition to the render-phase errors caught by `Sentry.ErrorBoundary`. Opt-in flags `includeNonFatalGlobalErrors` and `includeUnhandledRejections` extend the fallback to non-fatal errors and unhandled promise rejections respectively. ([#5930](https://github.com/getsentry/sentry-react-native/issues/5930)) +- Add `Sentry.GlobalErrorBoundary` component (and `withGlobalErrorBoundary` HOC) that renders a fallback UI for fatal non-rendering JS errors routed through `ErrorUtils` in addition to the render-phase errors caught by `Sentry.ErrorBoundary`. Opt-in flags `includeNonFatalGlobalErrors` and `includeUnhandledRejections` extend the fallback to non-fatal errors and unhandled promise rejections respectively. ([#6023](https://github.com/getsentry/sentry-react-native/pull/6023)) ### Dependencies diff --git a/packages/core/src/js/integrations/reactnativeerrorhandlers.ts b/packages/core/src/js/integrations/reactnativeerrorhandlers.ts index c8e0ce3b0e..f6cd6f6aa2 100644 --- a/packages/core/src/js/integrations/reactnativeerrorhandlers.ts +++ b/packages/core/src/js/integrations/reactnativeerrorhandlers.ts @@ -211,12 +211,6 @@ function setupErrorUtilsGlobalHandler(): void { // fatal/non-fatal preferences. publishGlobalError({ error, isFatal: !!isFatal, kind: 'onerror' }); - // If a GlobalErrorBoundary is interested in this error, we skip the - // default handler so the fallback UI can own the screen. Otherwise the - // default handler would unmount React (in release) or show LogBox (in dev) - // over our fallback. - const fallbackWillRender = hasInterestedSubscribers('onerror', !!isFatal); - if (__DEV__) { // If in dev, we call the default handler anyway and hope the error will be sent // Just for a better dev experience. If a fallback is mounted it will still @@ -227,7 +221,12 @@ function setupErrorUtilsGlobalHandler(): void { void client.flush((client.getOptions() as ReactNativeClientOptions).shutdownTimeout || 2000).then( () => { - if (!fallbackWillRender) { + // Re-check subscribers *after* the flush. The flush can take up to the + // configured shutdownTimeout (default 2s); a boundary could mount or + // unmount during that window, so the pre-flush answer may be stale. + // If a fallback will render, we skip the default handler so it can own + // the screen instead of being torn down. + if (!hasInterestedSubscribers('onerror', !!isFatal)) { defaultHandler(error, isFatal); } }, diff --git a/packages/core/test/integrations/reactnativeerrorhandlers.test.ts b/packages/core/test/integrations/reactnativeerrorhandlers.test.ts index 895eb347ba..c510f39d0d 100644 --- a/packages/core/test/integrations/reactnativeerrorhandlers.test.ts +++ b/packages/core/test/integrations/reactnativeerrorhandlers.test.ts @@ -250,6 +250,22 @@ describe('ReactNativeErrorHandlers', () => { expect(defaultHandler).not.toHaveBeenCalled(); }); + + test('re-evaluates subscribers after flush (boundary unmounts during flush)', async () => { + // Always returns false — simulates the boundary being gone by the + // time the flush resolves. The check must happen inside the .then, + // not before client.flush(), otherwise we'd never call defaultHandler. + hasSubscribersSpy.mockReturnValue(false); + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce!(); + + await errorHandlerCallback!(new Error('Boom'), true); + await client.flush(); + // Drain the .then() microtask attached to the integration's flush promise. + await new Promise(resolve => setImmediate(resolve)); + + expect(defaultHandler).toHaveBeenCalledTimes(1); + }); }); }); From 50ae5d22431a830ef6957790668a86ab380223e1 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 20 Apr 2026 11:40:17 +0200 Subject: [PATCH 03/12] chore(samples): Add GlobalErrorBoundary demo screen 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. --- packages/core/src/js/GlobalErrorBoundary.tsx | 4 +- .../react-native/src/Screens/ErrorsScreen.tsx | 6 + .../src/Screens/GlobalErrorBoundaryScreen.tsx | 158 ++++++++++++++++++ samples/react-native/src/tabs/ErrorsTab.tsx | 6 + 4 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 samples/react-native/src/Screens/GlobalErrorBoundaryScreen.tsx diff --git a/packages/core/src/js/GlobalErrorBoundary.tsx b/packages/core/src/js/GlobalErrorBoundary.tsx index fa3f4fcf50..c99423f7d0 100644 --- a/packages/core/src/js/GlobalErrorBoundary.tsx +++ b/packages/core/src/js/GlobalErrorBoundary.tsx @@ -142,7 +142,9 @@ export class GlobalErrorBoundary extends React.Component { }} /> +