Skip to content
Open
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 @@ -11,6 +11,7 @@
### Features

- Expose `pauseAppHangTracking` and `resumeAppHangTracking` APIs on iOS ([#6192](https://github.com/getsentry/sentry-react-native/pull/6192))
- Better route and dynamic param extraction for Expo Router ([#6197](https://github.com/getsentry/sentry-react-native/pull/6197))

## 8.12.0

Expand Down
48 changes: 48 additions & 0 deletions packages/core/src/js/tracing/expoRouterIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { Client, Integration } from '@sentry/core';

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

import type { RouteOverride } from './reactnavigation';

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

export const INTEGRATION_NAME = 'ExpoRouter';
Expand All @@ -13,8 +15,17 @@ 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 @@ -57,6 +68,8 @@ export const expoRouterIntegration = (options: ExpoRouterIntegrationOptions = {}

const navigationRef = store.navigationRef;

reactNavigation._setRouteOverrideProvider?.(() => buildExpoRouterRouteOverride(store));

if (navigationRef.current) {
reactNavigation.registerNavigationContainer(navigationRef);
return;
Expand Down Expand Up @@ -106,3 +119,38 @@ function tryGetExpoRouterStore(): ExpoRouterStore | null {
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 {
info = store.getRouteInfo?.();
} catch {
return undefined;
}
if (!info) {
return undefined;
}

const templatedPath = buildExpoRouterTemplatedPath(info.segments);
return {
templatedPath,
concreteUrl: info.pathnameWithParams ?? info.pathname,
params: info.params,
};
}
59 changes: 55 additions & 4 deletions packages/core/src/js/tracing/reactnavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,25 @@ export function extractDynamicRouteParams(
return Object.keys(result).length > 0 ? result : undefined;
}

/**
* Optional route override provided by another integration (e.g. Expo Router).
*
* When supplied, the route name and related attributes are derived from this
* override instead of React Navigation's `getCurrentRoute().name`, so we can
* report meaningful templated paths (e.g. `/profile/[id]`) instead of file
* names like `index` or `(tabs)`.
*/
export interface RouteOverride {
// templated pathname such as `/profile/[id]`. Used as the span name and `route.path` attribute
templatedPath: string;
// concrete URL with resolved values, e.g. `/profile/123?foo=bar`
concreteUrl?: string;
// merged route params (path + query)
params?: Record<string, unknown>;
}

export type RouteOverrideProvider = () => RouteOverride | undefined;

/**
* Builds a full path from the navigation state by traversing nested navigators.
* For example, with nested navigators: "Home/Settings/Profile"
Expand Down Expand Up @@ -193,9 +212,15 @@ export const reactNavigationIntegration = ({
* @param navigationContainerRef Ref to a `NavigationContainer`
*/
registerNavigationContainer: (navigationContainerRef: unknown) => void;
/**
* Internal API: allow another integration (for example, Expo Router integration) to
Copy link
Copy Markdown
Contributor

@antonis antonis May 21, 2026

Choose a reason for hiding this comment

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

I think an @internal annotation should make the api checker happy 🤞

Suggested change
* Internal API: allow another integration (for example, Expo Router integration) to
* @internal API: allow another integration (for example, Expo Router integration) to

* supply the canonical route info on every state change. Use `undefined` to clear.
*/
_setRouteOverrideProvider: (provider: RouteOverrideProvider | undefined) => void;
options: ReactNavigationIntegrationOptions;
} => {
let navigationContainer: NavigationContainer | undefined;
let routeOverrideProvider: RouteOverrideProvider | undefined;

let tracing: ReactNativeTracingIntegration | undefined;
let idleSpanOptions: Parameters<typeof startGenericIdleNavigationSpan>[1] = defaultIdleOptions;
Expand Down Expand Up @@ -474,9 +499,28 @@ export const reactNavigationIntegration = ({

const routeHasBeenSeen = recentRouteKeys.includes(route.key);

// Get the full navigation path for nested navigators
// Resolve route name. Order of preference:
// 1. Route override provider (e.g. Expo Router templated path)
// 2. Full path joined from React Navigation state
// 3. React Navigation's leaf route name
let routeName = route.name;
if (useFullPathsForNavigationRoutes) {
let routePath: string | undefined;
let routeUrl: string | undefined;
let routeParams: Record<string, unknown> | undefined = route.params;

let override: RouteOverride | undefined;
try {
override = routeOverrideProvider?.();
} catch (e) {
debug.warn(`${INTEGRATION_NAME} Route override provider threw, falling back to React Navigation route.`, e);
}

if (override?.templatedPath) {
routeName = override.templatedPath;
routePath = override.templatedPath;
routeUrl = override.concreteUrl;
routeParams = override.params ?? route.params;
} else if (useFullPathsForNavigationRoutes) {
const navigationState = navigationContainer.getState();
routeName = getPathFromState(navigationState) || route.name;
}
Expand All @@ -493,7 +537,9 @@ export const reactNavigationIntegration = ({
latestNavigationSpan.setAttributes({
'route.name': routeName,
'route.key': route.key,
...(sendDefaultPii ? extractDynamicRouteParams(routeName, route.params) : undefined),
...(routePath ? { 'route.path': routePath } : undefined),
...(sendDefaultPii && routeUrl ? { 'route.url': routeUrl } : undefined),
...(sendDefaultPii ? extractDynamicRouteParams(routeName, routeParams) : undefined),
'route.has_been_seen': routeHasBeenSeen,
'previous_route.name': previousRoute?.name,
'previous_route.key': previousRoute?.key,
Expand All @@ -517,7 +563,7 @@ export const reactNavigationIntegration = ({
tracing?.setCurrentRoute(routeName);

pushRecentRouteKey(route.key);
if (useFullPathsForNavigationRoutes) {
if (override?.templatedPath || useFullPathsForNavigationRoutes) {
latestRoute = { ...route, name: routeName };
} else {
latestRoute = route;
Expand Down Expand Up @@ -557,10 +603,15 @@ export const reactNavigationIntegration = ({
}
};

const _setRouteOverrideProvider = (provider: RouteOverrideProvider | undefined): void => {
routeOverrideProvider = provider;
};

return {
name: INTEGRATION_NAME,
afterAllSetup,
registerNavigationContainer,
_setRouteOverrideProvider,
options: {
routeChangeTimeoutMs,
enableTimeToInitialDisplay,
Expand Down
84 changes: 84 additions & 0 deletions packages/core/test/tracing/expoRouterIntegration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,90 @@ describe('expoRouterIntegration', () => {
});
});

describe('route override provider', () => {
it('registers a route override provider on reactNavigation derived from store.getRouteInfo', () => {
const container = createMockNavigationContainer();
const getRouteInfo = jest.fn(() => ({
segments: ['(tabs)', 'profile', '[id]'],
params: { id: '123', utm_source: 'email' },
pathnameWithParams: '/profile/123?utm_source=email',
pathname: '/profile/123',
}));
jest.doMock(
EXPO_ROUTER_STORE_MODULE,
() => ({
store: { navigationRef: { current: container }, getRouteInfo },
}),
{ virtual: true },
);

const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration');
const { client, addIntegration } = createMockClient();

const integ = integration();
integ.afterAllSetup?.(client);

const reactNavigation = addIntegration.mock.calls[0][0];
expect(typeof reactNavigation._setRouteOverrideProvider).toBe('function');

// The integration should have installed a provider on reactNavigation. We can
// grab it by spying on the setter.
const setProvider = reactNavigation._setRouteOverrideProvider as jest.Mock | ((p: unknown) => void);
// Since we don't have a spy, install one and re-run setup to capture the provider.
jest.resetModules();
jest.doMock(
EXPO_ROUTER_STORE_MODULE,
() => ({
store: { navigationRef: { current: container }, getRouteInfo },
}),
{ virtual: true },
);
const captured: { provider?: () => unknown } = {};
const { reactNavigationIntegration: actualRn } = require('../../src/js/tracing/reactnavigation');
const realRn = actualRn();
jest.spyOn(realRn, '_setRouteOverrideProvider').mockImplementation((p: unknown) => {
captured.provider = p as () => unknown;
});
const { client: client2, addIntegration: add2, getIntegrationByName: getByName2 } = createMockClient();
getByName2.mockImplementation((name: string) =>
name === REACT_NAVIGATION_INTEGRATION_NAME ? realRn : undefined,
);
const { expoRouterIntegration: integ2 } = require('../../src/js/tracing/expoRouterIntegration');
integ2().afterAllSetup?.(client2);
expect(add2).not.toHaveBeenCalled();

const result = captured.provider?.() as {
templatedPath: string;
concreteUrl?: string;
params?: Record<string, unknown>;
};
expect(result).toEqual({
templatedPath: '/profile/[id]',
concreteUrl: '/profile/123?utm_source=email',
params: { id: '123', utm_source: 'email' },
});
expect(getRouteInfo).toHaveBeenCalled();
// unused — silence ts-noemit warnings for captured `setProvider`
void setProvider;
});
});

describe('buildExpoRouterTemplatedPath', () => {
it('strips group segments and joins with /', () => {
const { buildExpoRouterTemplatedPath } = require('../../src/js/tracing/expoRouterIntegration');
expect(buildExpoRouterTemplatedPath(['(tabs)', 'profile', '[id]'])).toBe('/profile/[id]');
expect(buildExpoRouterTemplatedPath(['posts', '[...slug]'])).toBe('/posts/[...slug]');
expect(buildExpoRouterTemplatedPath(['(auth)', '(group)', 'login'])).toBe('/login');
});

it('returns / for empty or all-group segments (index routes)', () => {
const { buildExpoRouterTemplatedPath } = require('../../src/js/tracing/expoRouterIntegration');
expect(buildExpoRouterTemplatedPath([])).toBe('/');
expect(buildExpoRouterTemplatedPath(undefined)).toBe('/');
expect(buildExpoRouterTemplatedPath(['(tabs)'])).toBe('/');
});
});

describe('cleanup', () => {
it('clears the polling timer when the client closes', () => {
const navigationRef: { current: unknown } = { current: null };
Expand Down
Loading
Loading