Skip to content

Commit f7fff1d

Browse files
committed
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
1 parent 52d789a commit f7fff1d

7 files changed

Lines changed: 590 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
### Features
1212

1313
- Warn Expo users at Metro startup when prebuilt native projects are missing Sentry configuration ([#5984](https://github.com/getsentry/sentry-react-native/pull/5984))
14+
- 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))
1415

1516
### Dependencies
1617

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import type { ErrorBoundaryProps } from '@sentry/react';
2+
3+
import { ErrorBoundary } from '@sentry/react';
4+
import * as React from 'react';
5+
6+
import type { GlobalErrorEvent } from './integrations/globalErrorBus';
7+
8+
import { subscribeGlobalError } from './integrations/globalErrorBus';
9+
10+
/**
11+
* Props for {@link GlobalErrorBoundary}. Extends the standard `ErrorBoundary`
12+
* props from `@sentry/react` with two opt-ins that control which
13+
* non-rendering errors trigger the fallback UI.
14+
*/
15+
export type GlobalErrorBoundaryProps = ErrorBoundaryProps & {
16+
/**
17+
* If `true`, the fallback is also rendered for *non-fatal* errors routed
18+
* through `ErrorUtils` (React Native's global handler).
19+
*
20+
* Defaults to `false` — only fatals trigger the fallback, matching the
21+
* semantics of the native red-screen.
22+
*/
23+
includeNonFatalGlobalErrors?: boolean;
24+
25+
/**
26+
* If `true`, the fallback is also rendered for unhandled promise rejections.
27+
*
28+
* Defaults to `false` because many apps prefer to surface rejections as
29+
* toasts / inline errors rather than as a full-screen fallback.
30+
*/
31+
includeUnhandledRejections?: boolean;
32+
};
33+
34+
interface GlobalErrorThrowerProps {
35+
error: unknown | null;
36+
children?: React.ReactNode | (() => React.ReactNode);
37+
}
38+
39+
/**
40+
* Tiny component that re-throws a global error during render so the
41+
* surrounding `ErrorBoundary` catches it through the standard React path.
42+
*/
43+
class GlobalErrorThrower extends React.Component<GlobalErrorThrowerProps> {
44+
public render(): React.ReactNode {
45+
if (this.props.error !== null && this.props.error !== undefined) {
46+
// Throwing here routes the error into the surrounding ErrorBoundary's
47+
// getDerivedStateFromError / componentDidCatch lifecycle.
48+
throw this.props.error;
49+
}
50+
return typeof this.props.children === 'function' ? this.props.children() : this.props.children;
51+
}
52+
}
53+
54+
interface GlobalErrorBoundaryState {
55+
globalError: unknown | null;
56+
}
57+
58+
/**
59+
* An error boundary that also catches **non-rendering** fatal JS errors.
60+
*
61+
* In addition to the render-phase errors caught by `Sentry.ErrorBoundary`,
62+
* this component renders the provided fallback when:
63+
*
64+
* - A fatal error is reported through React Native's `ErrorUtils` global
65+
* handler (event handlers, timers, native → JS bridge errors, …).
66+
* - Optionally, non-fatal global errors (opt-in via
67+
* `includeNonFatalGlobalErrors`).
68+
* - Optionally, unhandled promise rejections (opt-in via
69+
* `includeUnhandledRejections`).
70+
*
71+
* The Sentry error pipeline (capture → flush → mechanism tagging) is
72+
* unchanged; this component only surfaces the fallback UI and suppresses
73+
* React Native's default fatal handler while the fallback is mounted.
74+
*
75+
* Intended usage is at the top of the component tree, typically just inside
76+
* `Sentry.wrap()`:
77+
*
78+
* ```tsx
79+
* <Sentry.GlobalErrorBoundary
80+
* fallback={({ error, resetError }) => (
81+
* <MyFallback error={error} onRetry={resetError} />
82+
* )}
83+
* >
84+
* <App />
85+
* </Sentry.GlobalErrorBoundary>
86+
* ```
87+
*/
88+
export class GlobalErrorBoundary extends React.Component<GlobalErrorBoundaryProps, GlobalErrorBoundaryState> {
89+
public state: GlobalErrorBoundaryState = { globalError: null };
90+
91+
private _unsubscribe?: () => void;
92+
private _latched = false;
93+
94+
public componentDidMount(): void {
95+
this._unsubscribe = subscribeGlobalError(this._onGlobalError, {
96+
fatal: true,
97+
nonFatal: !!this.props.includeNonFatalGlobalErrors,
98+
unhandledRejection: !!this.props.includeUnhandledRejections,
99+
});
100+
}
101+
102+
public componentWillUnmount(): void {
103+
this._unsubscribe?.();
104+
this._unsubscribe = undefined;
105+
}
106+
107+
public componentDidUpdate(prevProps: GlobalErrorBoundaryProps): void {
108+
// Re-subscribe if the opt-in flags change so the filter stays accurate.
109+
if (
110+
prevProps.includeNonFatalGlobalErrors !== this.props.includeNonFatalGlobalErrors ||
111+
prevProps.includeUnhandledRejections !== this.props.includeUnhandledRejections
112+
) {
113+
this._unsubscribe?.();
114+
this._unsubscribe = subscribeGlobalError(this._onGlobalError, {
115+
fatal: true,
116+
nonFatal: !!this.props.includeNonFatalGlobalErrors,
117+
unhandledRejection: !!this.props.includeUnhandledRejections,
118+
});
119+
}
120+
}
121+
122+
public render(): React.ReactNode {
123+
const {
124+
children,
125+
onReset,
126+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
127+
includeNonFatalGlobalErrors: _ignoredA,
128+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
129+
includeUnhandledRejections: _ignoredB,
130+
...forwarded
131+
} = this.props;
132+
133+
return (
134+
<ErrorBoundary {...forwarded} onReset={this._onReset(onReset)}>
135+
<GlobalErrorThrower error={this.state.globalError}>{children}</GlobalErrorThrower>
136+
</ErrorBoundary>
137+
);
138+
}
139+
140+
private _onGlobalError = (event: GlobalErrorEvent): void => {
141+
// Keep the first error — once the fallback is up, subsequent errors
142+
// shouldn't rewrite what the user is looking at. We use an instance flag
143+
// instead of reading state because multiple publishes can fire in the
144+
// same batch, before setState has flushed.
145+
if (this._latched) return;
146+
this._latched = true;
147+
this.setState({ globalError: event.error ?? new Error('Unknown global error') });
148+
};
149+
150+
private _onReset =
151+
(userOnReset: GlobalErrorBoundaryProps['onReset']) =>
152+
(error: unknown, componentStack: string, eventId: string): void => {
153+
this._latched = false;
154+
this.setState({ globalError: null });
155+
userOnReset?.(error, componentStack, eventId);
156+
};
157+
}
158+
159+
/**
160+
* HOC counterpart to {@link GlobalErrorBoundary}.
161+
*/
162+
export function withGlobalErrorBoundary<P extends Record<string, unknown>>(
163+
WrappedComponent: React.ComponentType<P>,
164+
errorBoundaryOptions: GlobalErrorBoundaryProps,
165+
): React.FC<P> {
166+
const componentDisplayName = WrappedComponent.displayName || WrappedComponent.name || 'unknown';
167+
168+
const Wrapped: React.FC<P> = props => (
169+
<GlobalErrorBoundary {...errorBoundaryOptions}>
170+
<WrappedComponent {...props} />
171+
</GlobalErrorBoundary>
172+
);
173+
174+
Wrapped.displayName = `globalErrorBoundary(${componentDisplayName})`;
175+
176+
return Wrapped;
177+
}

packages/core/src/js/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ export { ReactNativeClient } from './client';
7575

7676
export { init, wrap, nativeCrash, flush, close, withScope, crashedLastRun, appLoaded } from './sdk';
7777
export { TouchEventBoundary, withTouchEventBoundary } from './touchevents';
78+
export { GlobalErrorBoundary, withGlobalErrorBoundary } from './GlobalErrorBoundary';
79+
export type { GlobalErrorBoundaryProps } from './GlobalErrorBoundary';
7880

7981
export {
8082
reactNativeTracingIntegration,
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* Global error bus used by {@link GlobalErrorBoundary} to receive errors that
3+
* are captured outside the React render tree (e.g. `ErrorUtils` fatals,
4+
* unhandled promise rejections).
5+
*
6+
* The bus is intentionally tiny and stored on the global object so that it
7+
* survives Fast Refresh during development.
8+
*
9+
* This module is internal to the SDK.
10+
*/
11+
12+
import { RN_GLOBAL_OBJ } from '../utils/worldwide';
13+
14+
/** Where the error came from. */
15+
export type GlobalErrorKind = 'onerror' | 'onunhandledrejection';
16+
17+
/** Payload delivered to subscribers. */
18+
export interface GlobalErrorEvent {
19+
error: unknown;
20+
isFatal: boolean;
21+
kind: GlobalErrorKind;
22+
}
23+
24+
/** Options describing which kinds of errors a subscriber wants. */
25+
export interface GlobalErrorSubscriberOptions {
26+
/** Receive fatal `ErrorUtils` errors. Defaults to true. */
27+
fatal?: boolean;
28+
/** Receive non-fatal `ErrorUtils` errors. Defaults to false. */
29+
nonFatal?: boolean;
30+
/** Receive unhandled promise rejections. Defaults to false. */
31+
unhandledRejection?: boolean;
32+
}
33+
34+
type Listener = (event: GlobalErrorEvent) => void;
35+
36+
interface Subscriber {
37+
listener: Listener;
38+
options: Required<GlobalErrorSubscriberOptions>;
39+
}
40+
41+
interface BusState {
42+
subscribers: Set<Subscriber>;
43+
}
44+
45+
interface GlobalWithBus {
46+
__SENTRY_RN_GLOBAL_ERROR_BUS__?: BusState;
47+
}
48+
49+
function getBus(): BusState {
50+
const host = RN_GLOBAL_OBJ as unknown as GlobalWithBus;
51+
if (!host.__SENTRY_RN_GLOBAL_ERROR_BUS__) {
52+
host.__SENTRY_RN_GLOBAL_ERROR_BUS__ = { subscribers: new Set() };
53+
}
54+
return host.__SENTRY_RN_GLOBAL_ERROR_BUS__;
55+
}
56+
57+
/**
58+
* Subscribe to global errors. Returns an unsubscribe function.
59+
*/
60+
export function subscribeGlobalError(listener: Listener, options: GlobalErrorSubscriberOptions = {}): () => void {
61+
const subscriber: Subscriber = {
62+
listener,
63+
options: {
64+
fatal: options.fatal ?? true,
65+
nonFatal: options.nonFatal ?? false,
66+
unhandledRejection: options.unhandledRejection ?? false,
67+
},
68+
};
69+
getBus().subscribers.add(subscriber);
70+
return () => {
71+
getBus().subscribers.delete(subscriber);
72+
};
73+
}
74+
75+
/**
76+
* Returns true if at least one subscriber is interested in the given event.
77+
*
78+
* Used by the error handlers integration to decide whether to skip invoking
79+
* React Native's default error handler (which would otherwise tear down the
80+
* JS context and prevent any fallback UI from rendering).
81+
*/
82+
export function hasInterestedSubscribers(kind: GlobalErrorKind, isFatal: boolean): boolean {
83+
for (const { options } of getBus().subscribers) {
84+
if (kind === 'onerror') {
85+
if (isFatal ? options.fatal : options.nonFatal) return true;
86+
} else if (kind === 'onunhandledrejection' && options.unhandledRejection) {
87+
return true;
88+
}
89+
}
90+
return false;
91+
}
92+
93+
/**
94+
* Publish a global error to all interested subscribers.
95+
*/
96+
export function publishGlobalError(event: GlobalErrorEvent): void {
97+
for (const { listener, options } of getBus().subscribers) {
98+
if (event.kind === 'onerror') {
99+
if (event.isFatal ? options.fatal : options.nonFatal) listener(event);
100+
} else if (event.kind === 'onunhandledrejection' && options.unhandledRejection) {
101+
listener(event);
102+
}
103+
}
104+
}
105+
106+
/** Test-only: clear all subscribers. */
107+
export function _resetGlobalErrorBus(): void {
108+
getBus().subscribers.clear();
109+
}

packages/core/src/js/integrations/reactnativeerrorhandlers.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { ReactNativeClientOptions } from '../options';
1414
import { isHermesEnabled, isWeb } from '../utils/environment';
1515
import { createSyntheticError, isErrorLike } from '../utils/error';
1616
import { RN_GLOBAL_OBJ } from '../utils/worldwide';
17+
import { hasInterestedSubscribers, publishGlobalError } from './globalErrorBus';
1718
import { checkPromiseAndWarn, polyfillPromise, requireRejectionTracking } from './reactnativeerrorhandlersutils';
1819

1920
const INTEGRATION_NAME = 'ReactNativeErrorHandlers';
@@ -80,6 +81,7 @@ function setupUnhandledRejectionsTracking(patchGlobalPromise: boolean): void {
8081
syntheticException: isErrorLike(error) ? undefined : createSyntheticError(),
8182
mechanism: { handled: false, type: 'onunhandledrejection' },
8283
});
84+
publishGlobalError({ error, isFatal: false, kind: 'onunhandledrejection' });
8385
});
8486
} else if (patchGlobalPromise) {
8587
// For JSC and other environments, use the existing approach
@@ -113,6 +115,7 @@ const promiseRejectionTrackingOptions: PromiseRejectionTrackingOptions = {
113115
syntheticException: isErrorLike(error) ? undefined : createSyntheticError(),
114116
mechanism: { handled: true, type: 'onunhandledrejection' },
115117
});
118+
publishGlobalError({ error, isFatal: false, kind: 'onunhandledrejection' });
116119
},
117120
onHandled: id => {
118121
if (__DEV__) {
@@ -204,16 +207,29 @@ function setupErrorUtilsGlobalHandler(): void {
204207

205208
client.captureEvent(event, hint);
206209

210+
// Notify any mounted GlobalErrorBoundary. Subscribers filter internally by
211+
// fatal/non-fatal preferences.
212+
publishGlobalError({ error, isFatal: !!isFatal, kind: 'onerror' });
213+
214+
// If a GlobalErrorBoundary is interested in this error, we skip the
215+
// default handler so the fallback UI can own the screen. Otherwise the
216+
// default handler would unmount React (in release) or show LogBox (in dev)
217+
// over our fallback.
218+
const fallbackWillRender = hasInterestedSubscribers('onerror', !!isFatal);
219+
207220
if (__DEV__) {
208221
// If in dev, we call the default handler anyway and hope the error will be sent
209-
// Just for a better dev experience
222+
// Just for a better dev experience. If a fallback is mounted it will still
223+
// render alongside LogBox.
210224
defaultHandler(error, isFatal);
211225
return;
212226
}
213227

214228
void client.flush((client.getOptions() as ReactNativeClientOptions).shutdownTimeout || 2000).then(
215229
() => {
216-
defaultHandler(error, isFatal);
230+
if (!fallbackWillRender) {
231+
defaultHandler(error, isFatal);
232+
}
217233
},
218234
(reason: unknown) => {
219235
debug.error('[ReactNativeErrorHandlers] Error while flushing the event cache after uncaught error.', reason);

0 commit comments

Comments
 (0)