Skip to content

Commit 31c8c4f

Browse files
committed
Correct route and dynamic param extraction for Expo Router (#6157)
1 parent 5125c43 commit 31c8c4f

4 files changed

Lines changed: 330 additions & 4 deletions

File tree

‎packages/core/src/js/tracing/expoRouterIntegration.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import type { Client, Integration } from '@sentry/core';
22

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

5+
import type { RouteOverride } from './reactnavigation';
6+
57
import { getReactNavigationIntegration, reactNavigationIntegration } from './reactnavigation';
68

79
export const INTEGRATION_NAME = 'ExpoRouter';
@@ -13,8 +15,17 @@ interface ExpoRouterNavigationRef {
1315
current: unknown | null;
1416
}
1517

18+
interface ExpoRouterUrlObject {
19+
unstable_globalHref?: string;
20+
pathname?: string;
21+
pathnameWithParams?: string;
22+
params?: Record<string, unknown>;
23+
segments?: string[];
24+
}
25+
1626
interface ExpoRouterStore {
1727
navigationRef?: ExpoRouterNavigationRef;
28+
getRouteInfo?: () => ExpoRouterUrlObject;
1829
}
1930

2031
type ExpoRouterIntegrationOptions = Parameters<typeof reactNavigationIntegration>[0];
@@ -57,6 +68,8 @@ export const expoRouterIntegration = (options: ExpoRouterIntegrationOptions = {}
5768

5869
const navigationRef = store.navigationRef;
5970

71+
reactNavigation._setRouteOverrideProvider?.(() => buildExpoRouterRouteOverride(store));
72+
6073
if (navigationRef.current) {
6174
reactNavigation.registerNavigationContainer(navigationRef);
6275
return;
@@ -106,3 +119,38 @@ function tryGetExpoRouterStore(): ExpoRouterStore | null {
106119
return null;
107120
}
108121
}
122+
123+
/**
124+
* Builds a templated pathname from Expo Router's `segments`
125+
*
126+
* Examples:
127+
* ['(tabs)', 'profile', '[id]'] -> '/profile/[id]'
128+
* ['posts', '[...slug]'] -> '/posts/[...slug]'
129+
* [] -> '/'
130+
*/
131+
export function buildExpoRouterTemplatedPath(segments: string[] | undefined): string {
132+
if (!segments || segments.length === 0) {
133+
return '/';
134+
}
135+
const filtered = segments.filter(s => !(s.startsWith('(') && s.endsWith(')')));
136+
return filtered.length === 0 ? '/' : `/${filtered.join('/')}`;
137+
}
138+
139+
function buildExpoRouterRouteOverride(store: ExpoRouterStore): RouteOverride | undefined {
140+
let info: ExpoRouterUrlObject | undefined;
141+
try {
142+
info = store.getRouteInfo?.();
143+
} catch {
144+
return undefined;
145+
}
146+
if (!info) {
147+
return undefined;
148+
}
149+
150+
const templatedPath = buildExpoRouterTemplatedPath(info.segments);
151+
return {
152+
templatedPath,
153+
concreteUrl: info.pathnameWithParams ?? info.pathname,
154+
params: info.params,
155+
};
156+
}

‎packages/core/src/js/tracing/reactnavigation.ts‎

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,25 @@ export function extractDynamicRouteParams(
8787
return Object.keys(result).length > 0 ? result : undefined;
8888
}
8989

90+
/**
91+
* Optional route override provided by another integration (e.g. Expo Router).
92+
*
93+
* When supplied, the route name and related attributes are derived from this
94+
* override instead of React Navigation's `getCurrentRoute().name`, so we can
95+
* report meaningful templated paths (e.g. `/profile/[id]`) instead of file
96+
* names like `index` or `(tabs)`.
97+
*/
98+
export interface RouteOverride {
99+
// templated pathname such as `/profile/[id]`. Used as the span name and `route.path` attribute
100+
templatedPath: string;
101+
// concrete URL with resolved values, e.g. `/profile/123?foo=bar`
102+
concreteUrl?: string;
103+
// merged route params (path + query)
104+
params?: Record<string, unknown>;
105+
}
106+
107+
export type RouteOverrideProvider = () => RouteOverride | undefined;
108+
90109
/**
91110
* Builds a full path from the navigation state by traversing nested navigators.
92111
* For example, with nested navigators: "Home/Settings/Profile"
@@ -193,9 +212,15 @@ export const reactNavigationIntegration = ({
193212
* @param navigationContainerRef Ref to a `NavigationContainer`
194213
*/
195214
registerNavigationContainer: (navigationContainerRef: unknown) => void;
215+
/**
216+
* Internal API: allow another integration (for example, Expo Router integration) to
217+
* supply the canonical route info on every state change. Use `undefined` to clear.
218+
*/
219+
_setRouteOverrideProvider: (provider: RouteOverrideProvider | undefined) => void;
196220
options: ReactNavigationIntegrationOptions;
197221
} => {
198222
let navigationContainer: NavigationContainer | undefined;
223+
let routeOverrideProvider: RouteOverrideProvider | undefined;
199224

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

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

477-
// Get the full navigation path for nested navigators
502+
// Resolve route name. Order of preference:
503+
// 1. Route override provider (e.g. Expo Router templated path)
504+
// 2. Full path joined from React Navigation state
505+
// 3. React Navigation's leaf route name
478506
let routeName = route.name;
479-
if (useFullPathsForNavigationRoutes) {
507+
let routePath: string | undefined;
508+
let routeUrl: string | undefined;
509+
let routeParams: Record<string, unknown> | undefined = route.params;
510+
511+
let override: RouteOverride | undefined;
512+
try {
513+
override = routeOverrideProvider?.();
514+
} catch (e) {
515+
debug.warn(`${INTEGRATION_NAME} Route override provider threw, falling back to React Navigation route.`, e);
516+
}
517+
518+
if (override?.templatedPath) {
519+
routeName = override.templatedPath;
520+
routePath = override.templatedPath;
521+
routeUrl = override.concreteUrl;
522+
routeParams = override.params ?? route.params;
523+
} else if (useFullPathsForNavigationRoutes) {
480524
const navigationState = navigationContainer.getState();
481525
routeName = getPathFromState(navigationState) || route.name;
482526
}
@@ -493,7 +537,9 @@ export const reactNavigationIntegration = ({
493537
latestNavigationSpan.setAttributes({
494538
'route.name': routeName,
495539
'route.key': route.key,
496-
...(sendDefaultPii ? extractDynamicRouteParams(routeName, route.params) : undefined),
540+
...(routePath ? { 'route.path': routePath } : undefined),
541+
...(sendDefaultPii && routeUrl ? { 'route.url': routeUrl } : undefined),
542+
...(sendDefaultPii ? extractDynamicRouteParams(routeName, routeParams) : undefined),
497543
'route.has_been_seen': routeHasBeenSeen,
498544
'previous_route.name': previousRoute?.name,
499545
'previous_route.key': previousRoute?.key,
@@ -517,7 +563,7 @@ export const reactNavigationIntegration = ({
517563
tracing?.setCurrentRoute(routeName);
518564

519565
pushRecentRouteKey(route.key);
520-
if (useFullPathsForNavigationRoutes) {
566+
if (override?.templatedPath || useFullPathsForNavigationRoutes) {
521567
latestRoute = { ...route, name: routeName };
522568
} else {
523569
latestRoute = route;
@@ -557,10 +603,15 @@ export const reactNavigationIntegration = ({
557603
}
558604
};
559605

606+
const _setRouteOverrideProvider = (provider: RouteOverrideProvider | undefined): void => {
607+
routeOverrideProvider = provider;
608+
};
609+
560610
return {
561611
name: INTEGRATION_NAME,
562612
afterAllSetup,
563613
registerNavigationContainer,
614+
_setRouteOverrideProvider,
564615
options: {
565616
routeChangeTimeoutMs,
566617
enableTimeToInitialDisplay,

‎packages/core/test/tracing/expoRouterIntegration.test.ts‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,90 @@ describe('expoRouterIntegration', () => {
199199
});
200200
});
201201

202+
describe('route override provider', () => {
203+
it('registers a route override provider on reactNavigation derived from store.getRouteInfo', () => {
204+
const container = createMockNavigationContainer();
205+
const getRouteInfo = jest.fn(() => ({
206+
segments: ['(tabs)', 'profile', '[id]'],
207+
params: { id: '123', utm_source: 'email' },
208+
pathnameWithParams: '/profile/123?utm_source=email',
209+
pathname: '/profile/123',
210+
}));
211+
jest.doMock(
212+
EXPO_ROUTER_STORE_MODULE,
213+
() => ({
214+
store: { navigationRef: { current: container }, getRouteInfo },
215+
}),
216+
{ virtual: true },
217+
);
218+
219+
const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration');
220+
const { client, addIntegration } = createMockClient();
221+
222+
const integ = integration();
223+
integ.afterAllSetup?.(client);
224+
225+
const reactNavigation = addIntegration.mock.calls[0][0];
226+
expect(typeof reactNavigation._setRouteOverrideProvider).toBe('function');
227+
228+
// The integration should have installed a provider on reactNavigation. We can
229+
// grab it by spying on the setter.
230+
const setProvider = reactNavigation._setRouteOverrideProvider as jest.Mock | ((p: unknown) => void);
231+
// Since we don't have a spy, install one and re-run setup to capture the provider.
232+
jest.resetModules();
233+
jest.doMock(
234+
EXPO_ROUTER_STORE_MODULE,
235+
() => ({
236+
store: { navigationRef: { current: container }, getRouteInfo },
237+
}),
238+
{ virtual: true },
239+
);
240+
const captured: { provider?: () => unknown } = {};
241+
const { reactNavigationIntegration: actualRn } = require('../../src/js/tracing/reactnavigation');
242+
const realRn = actualRn();
243+
jest.spyOn(realRn, '_setRouteOverrideProvider').mockImplementation((p: unknown) => {
244+
captured.provider = p as () => unknown;
245+
});
246+
const { client: client2, addIntegration: add2, getIntegrationByName: getByName2 } = createMockClient();
247+
getByName2.mockImplementation((name: string) =>
248+
name === REACT_NAVIGATION_INTEGRATION_NAME ? realRn : undefined,
249+
);
250+
const { expoRouterIntegration: integ2 } = require('../../src/js/tracing/expoRouterIntegration');
251+
integ2().afterAllSetup?.(client2);
252+
expect(add2).not.toHaveBeenCalled();
253+
254+
const result = captured.provider?.() as {
255+
templatedPath: string;
256+
concreteUrl?: string;
257+
params?: Record<string, unknown>;
258+
};
259+
expect(result).toEqual({
260+
templatedPath: '/profile/[id]',
261+
concreteUrl: '/profile/123?utm_source=email',
262+
params: { id: '123', utm_source: 'email' },
263+
});
264+
expect(getRouteInfo).toHaveBeenCalled();
265+
// unused — silence ts-noemit warnings for captured `setProvider`
266+
void setProvider;
267+
});
268+
});
269+
270+
describe('buildExpoRouterTemplatedPath', () => {
271+
it('strips group segments and joins with /', () => {
272+
const { buildExpoRouterTemplatedPath } = require('../../src/js/tracing/expoRouterIntegration');
273+
expect(buildExpoRouterTemplatedPath(['(tabs)', 'profile', '[id]'])).toBe('/profile/[id]');
274+
expect(buildExpoRouterTemplatedPath(['posts', '[...slug]'])).toBe('/posts/[...slug]');
275+
expect(buildExpoRouterTemplatedPath(['(auth)', '(group)', 'login'])).toBe('/login');
276+
});
277+
278+
it('returns / for empty or all-group segments (index routes)', () => {
279+
const { buildExpoRouterTemplatedPath } = require('../../src/js/tracing/expoRouterIntegration');
280+
expect(buildExpoRouterTemplatedPath([])).toBe('/');
281+
expect(buildExpoRouterTemplatedPath(undefined)).toBe('/');
282+
expect(buildExpoRouterTemplatedPath(['(tabs)'])).toBe('/');
283+
});
284+
});
285+
202286
describe('cleanup', () => {
203287
it('clears the polling timer when the client closes', () => {
204288
const navigationRef: { current: unknown } = { current: null };

0 commit comments

Comments
 (0)