Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
177 changes: 177 additions & 0 deletions packages/core/src/js/GlobalErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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<GlobalErrorThrowerProps> {
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
* <Sentry.GlobalErrorBoundary
* fallback={({ error, resetError }) => (
* <MyFallback error={error} onRetry={resetError} />
* )}
* >
* <App />
* </Sentry.GlobalErrorBoundary>
* ```
*/
export class GlobalErrorBoundary extends React.Component<GlobalErrorBoundaryProps, GlobalErrorBoundaryState> {
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 (
<ErrorBoundary {...forwarded} onReset={this._onReset(onReset)}>
<GlobalErrorThrower error={this.state.globalError}>{children}</GlobalErrorThrower>
</ErrorBoundary>
Comment thread
sentry[bot] marked this conversation as resolved.
);
}

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<P extends Record<string, unknown>>(
WrappedComponent: React.ComponentType<P>,
errorBoundaryOptions: GlobalErrorBoundaryProps,
): React.FC<P> {
const componentDisplayName = WrappedComponent.displayName || WrappedComponent.name || 'unknown';

const Wrapped: React.FC<P> = props => (
<GlobalErrorBoundary {...errorBoundaryOptions}>
<WrappedComponent {...props} />
</GlobalErrorBoundary>
);

Wrapped.displayName = `globalErrorBoundary(${componentDisplayName})`;

return Wrapped;
}
2 changes: 2 additions & 0 deletions packages/core/src/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
109 changes: 109 additions & 0 deletions packages/core/src/js/integrations/globalErrorBus.ts
Original file line number Diff line number Diff line change
@@ -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<GlobalErrorSubscriberOptions>;
}

interface BusState {
subscribers: Set<Subscriber>;
}

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();
}
20 changes: 18 additions & 2 deletions packages/core/src/js/integrations/reactnativeerrorhandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
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';
Expand Down Expand Up @@ -80,6 +81,7 @@
syntheticException: isErrorLike(error) ? undefined : createSyntheticError(),
mechanism: { handled: false, type: 'onunhandledrejection' },
});
publishGlobalError({ error, isFatal: false, kind: 'onunhandledrejection' });
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Outdated
});
} else if (patchGlobalPromise) {
// For JSC and other environments, use the existing approach
Expand Down Expand Up @@ -113,6 +115,7 @@
syntheticException: isErrorLike(error) ? undefined : createSyntheticError(),
mechanism: { handled: true, type: 'onunhandledrejection' },
});
publishGlobalError({ error, isFatal: false, kind: 'onunhandledrejection' });
},
onHandled: id => {
if (__DEV__) {
Expand Down Expand Up @@ -204,16 +207,29 @@

client.captureEvent(event, hint);

// Notify any mounted GlobalErrorBoundary. Subscribers filter internally by
// fatal/non-fatal preferences.
publishGlobalError({ error, isFatal: !!isFatal, kind: 'onerror' });
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

// 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);
}

Check warning on line 232 in packages/core/src/js/integrations/reactnativeerrorhandlers.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

Race condition: fallbackWillRender check becomes stale during async flush

The `hasInterestedSubscribers` check at line 218 determines whether to skip the default error handler, but this decision is evaluated before the async `client.flush()` completes (up to 2 seconds). If a GlobalErrorBoundary unmounts during the flush period (e.g., due to parent component errors or navigation), the stale `fallbackWillRender=true` will prevent the default handler from being called, while no fallback UI actually renders. This leaves the app in a broken state with no error feedback to the user.
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Outdated
Comment thread
alwx marked this conversation as resolved.
},
(reason: unknown) => {
debug.error('[ReactNativeErrorHandlers] Error while flushing the event cache after uncaught error.', reason);
Expand Down
Loading
Loading