Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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 @@ -10,6 +10,7 @@

### Features

- Capture errors that hit Expo Router's per-route `ErrorBoundary`. Wrap the boundary with `Sentry.wrapRouterErrorBoundary(ErrorBoundary)` in your route file to capture render-phase errors with route context (`route.name`, `route.path`, `route.params`), tag the in-flight navigation transaction as errored, and emit a breadcrumb. Concrete paths and params are gated behind `sendDefaultPii` ([#6160](https://github.com/getsentry/sentry-react-native/issues/6160))
- Add `nativeStackAndroid` support to `NativeLinkedErrors`, capturing the JVM stack trace of rejected native module promises as a linked exception ([#6278](https://github.com/getsentry/sentry-react-native/pull/6278))
- Record XHR request/response headers and (optionally) bodies in Mobile Session Replay. Opt in via `mobileReplayIntegration` with `networkDetailAllowUrls` to capture headers; set `networkCaptureBodies: true` to also capture bodies. Other options: `networkDetailDenyUrls`, `networkRequestHeaders`, `networkResponseHeaders`. Authorization-like headers are always stripped, bodies are capped at ~150 KB. Covers XHR-based clients like `axios`; fetch will follow. See [Network Details](https://docs.sentry.io/platforms/react-native/session-replay/#network-details) for details. ([#6288](https://github.com/getsentry/sentry-react-native/pull/6288))
- Warn during dev builds when multiple versions of Sentry JS SDK are detected ([#6269](https://github.com/getsentry/sentry-react-native/pull/6269))
Expand Down
11 changes: 11 additions & 0 deletions packages/core/etc/sentry-react-native.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,14 @@ export interface ExpoRouter {
replace?: (...args: unknown[]) => void;
}

// @public
export interface ExpoRouterErrorBoundaryProps {
// (undocumented)
error: Error;
// (undocumented)
retry: () => Promise<void>;
}

// Warning: (ae-forgotten-export) The symbol "ExpoRouterIntegrationOptions" needs to be exported by the entry point index.d.ts
//
// @public
Expand Down Expand Up @@ -890,6 +898,9 @@ export function wrapExpoImage<T extends ExpoImage>(imageClass: T): T;
// @public
export function wrapExpoRouter<T extends ExpoRouter>(router: T): T;

// @public
export function wrapRouterErrorBoundary<P extends ExpoRouterErrorBoundaryProps>(OriginalErrorBoundary: React_2.ComponentType<P>): React_2.ComponentType<P>;

// @public
export function wrapTurboModule<T extends object>(name: string, module: T | null | undefined, options?: {
skip?: ReadonlyArray<string>;
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,13 @@
createTimeToFullDisplay,
createTimeToInitialDisplay,
wrapExpoRouter,
expoRouterIntegration,

Check warning on line 136 in packages/core/src/js/index.ts

View check run for this annotation

@sentry/warden / warden: code-review

[JQW-LSF] Side effects (captureException, addBreadcrumb, span mutation) executed during React render phase (additional location)

Calling `reportRouterBoundaryError` synchronously inside the render body violates React's rule that renders must be pure; in React 18 concurrent mode, renders can be interrupted and the ref mutation on line 58 will suppress the deduplication guard without the error ever being reported after commit. Move to a `useEffect(() => { โ€ฆ }, [props.error])` to tie the side effect to the commit phase.
wrapRouterErrorBoundary,
wrapExpoImage,
wrapExpoAsset,
} from './tracing';

export type { TimeToDisplayProps, ExpoRouter, ExpoImage, ExpoAsset } from './tracing';
export type { TimeToDisplayProps, ExpoRouter, ExpoRouterErrorBoundaryProps, ExpoImage, ExpoAsset } from './tracing';

export { Mask, Unmask } from './replay/CustomMask';

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

import {
addBreadcrumb,
addExceptionMechanism,
captureException,
getActiveSpan,
getClient,
getRootSpan,
SPAN_STATUS_ERROR,
} from '@sentry/core';
import * as React from 'react';

import { getCurrentExpoRouterRouteInfo } from './expoRouterStore';

/**
* The minimal shape of Expo Router's per-route `ErrorBoundary` props.
*
* We re-declare it here to avoid a hard dependency on `expo-router`.
*/
export interface ExpoRouterErrorBoundaryProps {
error: Error;
retry: () => Promise<void>;
}

/**
* Wraps Expo Router's per-route `ErrorBoundary` so that the SDK captures
* errors that hit the boundary instead of relying on the user's global error
* handler.
*
* Expo Router renders the boundary exported from a route file
* (`export { ErrorBoundary } from 'expo-router'`) when a component throws
* during render. Without this wrapper, Sentry only sees the error if it also
* reaches `ErrorUtils` โ€” which it often does not, because React swallows the
* error once a boundary handles it.
*
* For each new `error` instance the wrapper:
* - Captures the error to Sentry with `route.name`, `route.path`, and
* `route.params` attached, gated by `sendDefaultPii` for concrete fields.
* - Tags the active idle navigation span (and its root) with
* `SPAN_STATUS_ERROR` so the navigation transaction reflects the failure.
* - Adds a breadcrumb describing the boundary render.
*
* @example
* ```ts
* // app/_layout.tsx\n * import { ErrorBoundary as ExpoErrorBoundary } from 'expo-router';\n * import * as Sentry from '@sentry/react-native';\n *\n * export const ErrorBoundary = Sentry.wrapRouterErrorBoundary(ExpoErrorBoundary);\n * ```\n */
export function wrapRouterErrorBoundary<P extends ExpoRouterErrorBoundaryProps>(
OriginalErrorBoundary: React.ComponentType<P>,
): React.ComponentType<P> {
const Wrapped: React.FC<P> = props => {
// Track the last error instance we reported so a re-render with the same
// error does not produce a duplicate event. Resets when `retry()` clears
// the error and React unmounts the boundary.
const reportedErrorRef = React.useRef<unknown>(null);

if (reportedErrorRef.current !== props.error && props.error) {
reportedErrorRef.current = props.error;
reportRouterBoundaryError(props.error);
}

Check warning on line 60 in packages/core/src/js/tracing/expoRouterErrorBoundary.tsx

View check run for this annotation

@sentry/warden / warden: code-review

Side effects (captureException, addBreadcrumb, span mutation) executed during React render phase

Calling `reportRouterBoundaryError` synchronously inside the render body violates React's rule that renders must be pure; in React 18 concurrent mode, renders can be interrupted and the ref mutation on line 58 will suppress the deduplication guard without the error ever being reported after commit. Move to a `useEffect(() => { โ€ฆ }, [props.error])` to tie the side effect to the commit phase.
Comment on lines +57 to +60

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Side effects (captureException, addBreadcrumb, span mutation) executed during React render phase

Calling reportRouterBoundaryError synchronously inside the render body violates React's rule that renders must be pure; in React 18 concurrent mode, renders can be interrupted and the ref mutation on line 58 will suppress the deduplication guard without the error ever being reported after commit. Move to a useEffect(() => { โ€ฆ }, [props.error]) to tie the side effect to the commit phase.

Evidence
  • reportRouterBoundaryError calls addBreadcrumb, captureException, and root.setStatus โ€” all observable external side effects.
  • reportedErrorRef.current = props.error (line 58) mutates a ref during render, which React explicitly warns against in concurrent mode since the same render may run multiple times before committing.
  • If React discards the in-progress render (e.g. due to a higher-priority update), the ref is already set to the error value so the guard on line 57 will prevent a second attempt on the retry render, silently dropping the capture.
  • The tests invoke render(...) synchronously without Strict Mode wrapping, so this double-invoke / discard scenario is not exercised.
  • The correct pattern is useEffect(() => { if (props.error && reportedErrorRef.current !== props.error) { reportedErrorRef.current = props.error; reportRouterBoundaryError(props.error); } }, [props.error]);
Also found at 1 additional location
  • packages/core/src/js/index.ts:136

Identified by Warden code-review ยท JQW-LSF

return <OriginalErrorBoundary {...props} />;

Check warning on line 61 in packages/core/src/js/tracing/expoRouterErrorBoundary.tsx

View check run for this annotation

@sentry/warden / warden: find-bugs

[8C5-WUC] Sentry side effects fired during render body can duplicate captures under Strict Mode / concurrent rendering (additional location)

In `wrapRouterErrorBoundary` (`expoRouterErrorBoundary.tsx`), `reportRouterBoundaryError` โ€” which calls `captureException`, `addBreadcrumb`, and `root.setStatus` โ€” is invoked synchronously inside the functional component's render body, guarded only by a `useRef` deduplication. React intentionally unmounts and remounts components under Strict Mode (dev) and may render-then-discard components under concurrent rendering; either case resets the `useRef(null)` guard or fires the side effect for a render that is never committed, producing duplicate or spurious Sentry events for the same error. Side effects should run in `useEffect(() => { if (props.error) reportRouterBoundaryError(props.error); }, [props.error])`, which fires once per unique error, survives remounts, and removes the need for the manual ref guard.
};

Wrapped.displayName = `wrapRouterErrorBoundary(${
OriginalErrorBoundary.displayName || OriginalErrorBoundary.name || 'ErrorBoundary'
})`;

return Wrapped as React.ComponentType<P>;
}

function reportRouterBoundaryError(error: Error): void {
const sendPii = getClient()?.getOptions()?.sendDefaultPii ?? false;
const route = getCurrentExpoRouterRouteInfo();

const templatedPath = route?.templatedPath;
// `templatedPath` (e.g. `/users/[id]`) is structural and safe; concrete path
// (e.g. `/users/42`) and `params` may contain identifiers and are PII-gated.
const routeName = templatedPath ?? 'unknown';
const concretePath = sendPii ? (route?.pathnameWithParams ?? route?.pathname) : undefined;

addBreadcrumb({
category: 'expo-router.error_boundary',
type: 'error',
level: 'error',
message: `Expo Router ErrorBoundary rendered for ${routeName}`,
data: {
'route.name': routeName,
...(concretePath ? { 'route.path': concretePath } : undefined),
},
});

markActiveNavigationSpanErrored();

captureException(error, (scope: Scope) => {
scope.setTag('expo_router.error_boundary', 'true');
scope.setContext('route', {
name: routeName,
...(concretePath ? { path: concretePath } : undefined),
...(sendPii && route?.params ? { params: route.params } : undefined),
...(route?.segments ? { segments: route.segments } : undefined),
});
scope.addEventProcessor(event => {
addExceptionMechanism(event, { type: 'expo_router_error_boundary', handled: true });
return event;
});
return scope;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sentry failure blocks error UI

High Severity

reportRouterBoundaryError runs synchronously during the wrapped boundaryโ€™s render, before OriginalErrorBoundary is returned. Nothing wraps addBreadcrumb, captureException, or span status updates, so any thrown Sentry instrumentation error aborts render and can prevent Expo Routerโ€™s fallback UI from showing.

Fix in Cursorย Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit f313648. Configure here.

}

/**
* If an idle navigation span (or any child) is still open when the boundary
* renders, mark its root as errored so the resulting transaction reflects the
* navigation failure. Scoped to navigation roots so that a user-started
* custom span is not retroactively flipped to errored. No-op otherwise.
*/
function markActiveNavigationSpanErrored(): void {
const active = getActiveSpan();
if (!active) {
return;
}
const root = getRootSpan(active);
const origin = (root as { attributes?: Record<string, unknown> })?.attributes?.['sentry.origin'];
if (typeof origin !== 'string' || !origin.startsWith('auto.navigation.')) {
return;
}
root.setStatus({ code: SPAN_STATUS_ERROR, message: 'expo_router_error_boundary' });
}
49 changes: 4 additions & 45 deletions packages/core/src/js/tracing/expoRouterIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,19 @@ import type { Client, Integration } from '@sentry/core';

import { debug } from '@sentry/core';

import type { ExpoRouterStore, ExpoRouterUrlObject } from './expoRouterStore';
import type { RouteOverride } from './reactnavigation';

import { buildExpoRouterTemplatedPath, tryGetExpoRouterStore } from './expoRouterStore';
import { getReactNavigationIntegration, reactNavigationIntegration } from './reactnavigation';

export { buildExpoRouterTemplatedPath };

export const INTEGRATION_NAME = 'ExpoRouter';

const POLL_INTERVAL_MS = 50;
const POLL_MAX_DURATION_MS = 5_000;

interface ExpoRouterNavigationRef {
current: unknown | null;
}

interface ExpoRouterUrlObject {
unstable_globalHref?: string;
pathname?: string;
pathnameWithParams?: string;
params?: Record<string, unknown>;
segments?: string[];
}

interface ExpoRouterStore {
navigationRef?: ExpoRouterNavigationRef;
getRouteInfo?: () => ExpoRouterUrlObject;
}

type ExpoRouterIntegrationOptions = Parameters<typeof reactNavigationIntegration>[0];

/**
Expand Down Expand Up @@ -108,34 +95,6 @@ export const expoRouterIntegration = (options: ExpoRouterIntegrationOptions = {}
};
};

function tryGetExpoRouterStore(): ExpoRouterStore | null {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mod = require('expo-router/build/global-state/router-store') as {
store?: ExpoRouterStore;
};
return mod?.store ?? null;
} catch {
return null;
}
}

/**
* Builds a templated pathname from Expo Router's `segments`
*
* Examples:
* ['(tabs)', 'profile', '[id]'] -> '/profile/[id]'
* ['posts', '[...slug]'] -> '/posts/[...slug]'
* [] -> '/'
*/
export function buildExpoRouterTemplatedPath(segments: string[] | undefined): string {
if (!segments || segments.length === 0) {
return '/';
}
const filtered = segments.filter(s => !(s.startsWith('(') && s.endsWith(')')));
return filtered.length === 0 ? '/' : `/${filtered.join('/')}`;
}

function buildExpoRouterRouteOverride(store: ExpoRouterStore): RouteOverride | undefined {
let info: ExpoRouterUrlObject | undefined;
try {
Expand Down
98 changes: 98 additions & 0 deletions packages/core/src/js/tracing/expoRouterStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* Shared helpers for reading Expo Router's internal router store.
*
* Used by:
* - {@link expoRouterIntegration} to attach the current route to the idle
* navigation span via {@link RouteOverride}.
* - {@link wrapRouterErrorBoundary} to attach the current route to errors
* surfaced through Expo Router's per-route `ErrorBoundary`.
*/

export interface ExpoRouterNavigationRef {
current: unknown | null;
}

export interface ExpoRouterUrlObject {
unstable_globalHref?: string;
pathname?: string;
pathnameWithParams?: string;
params?: Record<string, unknown>;
segments?: string[];
}

export interface ExpoRouterStore {
navigationRef?: ExpoRouterNavigationRef;
getRouteInfo?: () => ExpoRouterUrlObject;
}

export interface NormalizedExpoRouterRouteInfo {
/**
* Templated pathname with grouping segments (`(tabs)`) removed. Safe to send
* regardless of `sendDefaultPii`. Examples:
* ['(tabs)', 'profile', '[id]'] -> '/profile/[id]'
* ['posts', '[...slug]'] -> '/posts/[...slug]'
* [] -> '/'
*/
templatedPath: string;
/** Concrete pathname (may contain user identifiers). Caller decides PII handling. */
pathname?: string;
/** Concrete pathname including query/params (may contain PII). */
pathnameWithParams?: string;
params?: Record<string, unknown>;
segments?: string[];
}

/**
* Returns Expo Router's internal router store, or `null` if `expo-router` is
* not installed or the build does not expose the expected module path.
*/
export function tryGetExpoRouterStore(): ExpoRouterStore | null {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mod = require('expo-router/build/global-state/router-store') as {
store?: ExpoRouterStore;
};
return mod?.store ?? null;
} catch {
return null;
}
}

/**
* Builds a templated pathname from Expo Router's `segments`. Grouping segments
* (e.g. `(tabs)`, `(auth)`) are stripped because they do not appear in the URL.
*/
export function buildExpoRouterTemplatedPath(segments: string[] | undefined): string {
if (!segments || segments.length === 0) {
return '/';
}
const filtered = segments.filter(s => !(s.startsWith('(') && s.endsWith(')')));
return filtered.length === 0 ? '/' : `/${filtered.join('/')}`;
}

/**
* Reads the current route from Expo Router's store and normalizes it. Returns
* `undefined` if the store is not reachable or `getRouteInfo` throws.
*/
export function getCurrentExpoRouterRouteInfo(): NormalizedExpoRouterRouteInfo | undefined {
const store = tryGetExpoRouterStore();
if (!store) {
return undefined;
}
let info: ExpoRouterUrlObject | undefined;
try {
info = store.getRouteInfo?.();
} catch {
return undefined;
}
if (!info) {
return undefined;
}
return {
templatedPath: buildExpoRouterTemplatedPath(info.segments),
pathname: info.pathname,
pathnameWithParams: info.pathnameWithParams,
params: info.params,
segments: info.segments,
};
}
3 changes: 3 additions & 0 deletions packages/core/src/js/tracing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
export type { ExpoRouter } from './expoRouter';

export { expoRouterIntegration } from './expoRouterIntegration';

Check warning on line 16 in packages/core/src/js/tracing/index.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

Sentry side effects fired during render body can duplicate captures under Strict Mode / concurrent rendering

In `wrapRouterErrorBoundary` (`expoRouterErrorBoundary.tsx`), `reportRouterBoundaryError` โ€” which calls `captureException`, `addBreadcrumb`, and `root.setStatus` โ€” is invoked synchronously inside the functional component's render body, guarded only by a `useRef` deduplication. React intentionally unmounts and remounts components under Strict Mode (dev) and may render-then-discard components under concurrent rendering; either case resets the `useRef(null)` guard or fires the side effect for a render that is never committed, producing duplicate or spurious Sentry events for the same error. Side effects should run in `useEffect(() => { if (props.error) reportRouterBoundaryError(props.error); }, [props.error])`, which fires once per unique error, survives remounts, and removes the need for the manual ref guard.
export { wrapRouterErrorBoundary } from './expoRouterErrorBoundary';
export type { ExpoRouterErrorBoundaryProps } from './expoRouterErrorBoundary';

export { wrapExpoImage } from './expoImage';
export type { ExpoImage } from './expoImage';

Expand Down
Loading
Loading