Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
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. ([#6023](https://github.com/getsentry/sentry-react-native/pull/6023))

### Dependencies

Expand Down
191 changes: 191 additions & 0 deletions packages/core/src/js/GlobalErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import type { ErrorBoundaryProps } from '@sentry/react';

import { lastEventId } from '@sentry/core';
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 GlobalErrorBoundaryState {
globalError: unknown | null;
globalEventId: string;
}

/**
* 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) runs in the
* integration before this component is notified, so the fallback UI surfaces
* an already-captured event and does not generate a duplicate.
*
* 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, globalEventId: '' };

private _unsubscribe?: () => void;
private _latched = false;

public componentDidMount(): void {
this._subscribe();
}

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._subscribe();
}
}

public render(): React.ReactNode {
const { globalError, globalEventId } = this.state;
const {
children,
fallback,
// 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;

// Global-error path: render the fallback directly. The error was already
// captured by the integration before the bus published it, so we must NOT
// route it through @sentry/react's ErrorBoundary โ€” its componentDidCatch
// would call captureReactException and produce a duplicate Sentry event.
if (globalError !== null && globalError !== undefined) {
if (typeof fallback === 'function') {
return fallback({
error: globalError,
componentStack: '',
eventId: globalEventId,
resetError: this._resetFromFallback,
});
}
return fallback ?? null;
}

// Render-phase path: delegate to the upstream ErrorBoundary untouched.
return (
<ErrorBoundary {...forwarded} fallback={fallback} onReset={this._onRenderBoundaryReset}>
{children}
</ErrorBoundary>
Comment thread
sentry[bot] marked this conversation as resolved.
);
}

private _subscribe(): void {
this._unsubscribe = subscribeGlobalError(this._onGlobalError, {
fatal: true,
nonFatal: !!this.props.includeNonFatalGlobalErrors,
unhandledRejection: !!this.props.includeUnhandledRejections,
});
}

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;

Check warning on line 149 in packages/core/src/js/GlobalErrorBoundary.tsx

View check run for this annotation

@sentry/warden / warden: code-review

Race condition with lastEventId() may return wrong event ID

The `_onGlobalError` method calls `lastEventId()` to get the event ID for the error that just occurred. However, `lastEventId()` returns the globally last captured event ID, not necessarily the ID of the current error. If multiple errors are captured in quick succession (e.g., multiple components failing), or if another event is captured between the integration's capture and the bus notification, the wrong event ID could be associated with this error in the fallback UI.
Comment thread
sentry-warden[bot] marked this conversation as resolved.
const error = event.error ?? new Error('Unknown global error');
// The integration captured the event just before publishing, so the
// current lastEventId is ours. Fall back to '' to match the type contract.
const eventId = lastEventId() ?? '';

this.setState({ globalError: error, globalEventId: eventId });
this.props.onError?.(error, '', eventId);
};

private _resetFromFallback = (): void => {
const { globalError, globalEventId } = this.state;
this._latched = false;
this.setState({ globalError: null, globalEventId: '' });
this.props.onReset?.(globalError, '', globalEventId);
};

private _onRenderBoundaryReset = (error: unknown, componentStack: string, eventId: string): void => {
// Delegate to the user's onReset for render-phase resets; no internal
// state to clear on this path.
this.props.onReset?.(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();
}
26 changes: 24 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,20 +207,39 @@

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 (__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);
// 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);
}
Comment thread
alwx marked this conversation as resolved.
// Release the latch so any subsequent fatal can still be captured.
// Before GlobalErrorBoundary existed, the default handler always tore
// the app down, so the latch was effectively permanent. Now the app
// can survive the first fatal via the fallback UI, and a later fatal
// must flow through the full capture + publish pipeline.
handlingFatal = false;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
},
(reason: unknown) => {
debug.error('[ReactNativeErrorHandlers] Error while flushing the event cache after uncaught error.', reason);
handlingFatal = false;
},

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

View check run for this annotation

@sentry/warden / warden: find-bugs

Fatal error silently swallowed when flush fails and no GlobalErrorBoundary is mounted

When `client.flush()` rejects (line 239-242), the rejection handler resets `handlingFatal` and logs the error, but never calls `defaultHandler`. In the success path (line 229-231), `defaultHandler` is called when no subscribers exist. This asymmetry means a fatal error occurring when flush fails AND no GlobalErrorBoundary is subscribed will be silently swallowed - the app continues running without showing the native red screen or terminating, leaving the user unaware of the critical failure.
);
});
}
Expand Down
Loading
Loading