Skip to content

Commit 2c53f06

Browse files
authored
fix(tracing): Support Expo Router 58 in expoRouterIntegration (#6923)
* fix(tracing): Support Expo Router 58 in expoRouterIntegration Expo Router 58 removed the internal router-store module and no longer emits React Navigation's __unsafe_action__ event. Because 58 declares package exports, Metro printed an invalid package.json warning for the router-store require on every bundle, and the integration could not start navigation spans. The navigation container ref now comes from the public useNavigationContainerRef export, which returns a module-level ref in Expo Router 57 and 58. Route info is computed from the root navigation state with getRouteInfoFromState, which both versions ship at the same path. On Expo Router 58 the integration listens to the internal actionDispatched event and starts the idle navigation span through a new internal _startIdleNavigationSpan method on reactNavigationIntegration. Expo Router 57 has no exports field, so the missing internalNavigationEvents path is a silent optional dependency there and spans still start from __unsafe_action__. Fixes #6901 * docs: Add the PR number to the Expo Router 58 changelog entry Replace the placeholder in the CHANGELOG entry with the PR number, #6923. * fix(tracing): Keep expoRouterIntegration working on Expo Router 55 to 57 Review of #6923 found two regressions for Expo Router versions before 58. Expo Router 55 and older do not ship getRouteInfoFromState, so the require threw and the integration stopped as if expo-router were not installed. The route info module is now optional. Without it, the integration still registers the navigation container and skips the route override, so spans use the React Navigation route names. In Expo Router 56 and 57, useNavigationContainerRef reads a store that stays empty until the Root Layout renders. The ref was read once at Sentry.init, so polling timed out and brought back #6431. The store now reads the ref on every access, and a throwing call returns undefined so it cannot escape the poll timer. The #6431 regression test is restored with the new mocks, and new tests cover a missing getRouteInfoFromState and a throwing useNavigationContainerRef. The CHANGELOG entry moves back to the Unreleased section after the 8.32.0 release. * fix(tracing): Start Expo Router 58 spans only for navigation actions Expo Router 58 emits actionDispatched for every reducer action, not only user navigations. On a cold launch with tabs it sends ROUTE_NAMES_CHANGED and one PRELOAD per tab, and router.prefetch() and param updates send more PRELOAD, ROUTE_NAMES_CHANGED, and SET_PARAMS actions. expoRouterIntegration does not enable useDispatchedActionData, so startIdleNavigationSpan did not filter these. Each one started an idle span that shipped as an empty "Route Change" transaction on the idle timeout. The listener now forwards only the known navigation action types (NAVIGATE, PUSH, REPLACE, GO_BACK, POP, POP_TO, POP_TO_TOP, JUMP_TO, RESET). An allowlist is safer than a denylist because the router 58 reducer also has internal actions such as REMOVE_ROUTES and NAVIGATOR_CHANGED.
1 parent 18da629 commit 2c53f06

8 files changed

Lines changed: 369 additions & 93 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- Apply `screenshot` masking options to Android error and feedback screenshots ([#6930](https://github.com/getsentry/sentry-react-native/pull/6930))
1414
- Persist fatal JS error envelopes to disk on Android before the process exits ([#6937](https://github.com/getsentry/sentry-react-native/pull/6937))
1515
- The hard-crash envelope was only queued on the transport thread, so the event was lost when React Native terminated the process right after. It is now written to the offline cache on the calling thread and sent on the next launch, matching iOS.
16+
- Support Expo Router 58 in `expoRouterIntegration`. Metro no longer warns about the removed `expo-router/build/global-state/router-store` module, and the integration creates navigation spans again ([#6923](https://github.com/getsentry/sentry-react-native/pull/6923))
1617

1718
### Internal
1819

‎packages/core/etc/sentry-react-native.api.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -931,6 +931,7 @@ export interface ReactNativeWrapperOptions {
931931
export const reactNavigationIntegration: (input?: Partial<ReactNavigationIntegrationOptions>) => Integration & {
932932
registerNavigationContainer: (navigationContainerRef: unknown) => void;
933933
_setRouteOverrideProvider: (provider: RouteOverrideProvider | undefined) => void;
934+
_startIdleNavigationSpan: (event: UnsafeAction) => void;
934935
options: ReactNavigationIntegrationOptions;
935936
};
936937

@@ -1337,6 +1338,7 @@ export function wrapTurboModule<T extends object>(name: string, module: T | null
13371338
//
13381339
// src/js/tracing/reactnativetracing.ts:93:3 - (ae-forgotten-export) The symbol "ReactNativeTracingState" needs to be exported by the entry point index.d.ts
13391340
// src/js/tracing/reactnavigation.ts:228:3 - (ae-forgotten-export) The symbol "RouteOverrideProvider" needs to be exported by the entry point index.d.ts
1341+
// src/js/tracing/reactnavigation.ts:233:3 - (ae-forgotten-export) The symbol "UnsafeAction" needs to be exported by the entry point index.d.ts
13401342

13411343
// (No @packageDocumentation comment for this package)
13421344

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

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import { debug } from '@sentry/core';
55
import type { ExpoRouterStore, ExpoRouterUrlObject } from './expoRouterStore';
66
import type { RouteOverride } from './reactnavigation';
77

8-
import { buildExpoRouterTemplatedPath, tryGetExpoRouterStore } from './expoRouterStore';
8+
import {
9+
buildExpoRouterTemplatedPath,
10+
tryGetExpoRouterNavigationEvents,
11+
tryGetExpoRouterStore,
12+
} from './expoRouterStore';
913
import { getReactNavigationIntegration, reactNavigationIntegration } from './reactnavigation';
1014

1115
export { buildExpoRouterTemplatedPath };
@@ -15,6 +19,21 @@ export const INTEGRATION_NAME = 'ExpoRouter';
1519
const POLL_INTERVAL_MS = 50;
1620
const POLL_MAX_DURATION_MS = 5_000;
1721

22+
// Expo Router 58 emits `actionDispatched` for every reducer action, including internal
23+
// ones such as `ROUTE_NAMES_CHANGED`, `PRELOAD`, and `SET_PARAMS`. Only user navigations
24+
// start a navigation span; the rest would ship as empty "Route Change" transactions.
25+
const NAVIGATION_ACTION_TYPES = new Set([
26+
'NAVIGATE',
27+
'PUSH',
28+
'REPLACE',
29+
'GO_BACK',
30+
'POP',
31+
'POP_TO',
32+
'POP_TO_TOP',
33+
'JUMP_TO',
34+
'RESET',
35+
]);
36+
1837
export type ExpoRouterIntegrationOptions = Parameters<typeof reactNavigationIntegration>[0];
1938

2039
/**
@@ -30,6 +49,7 @@ export type ExpoRouterIntegrationOptions = Parameters<typeof reactNavigationInte
3049
*/
3150
export const expoRouterIntegration = (options: ExpoRouterIntegrationOptions = {}): Integration => {
3251
let pollTimer: ReturnType<typeof setTimeout> | undefined;
52+
let removeActionListener: (() => void) | undefined;
3353

3454
const afterAllSetup = (client: Client): void => {
3555
const store = tryGetExpoRouterStore();
@@ -58,14 +78,26 @@ export const expoRouterIntegration = (options: ExpoRouterIntegrationOptions = {}
5878
}
5979
reactNavigation._setRouteOverrideProvider?.(() => buildExpoRouterRouteOverride(store));
6080
reactNavigation.registerNavigationContainer(navigationRef);
81+
removeActionListener = tryGetExpoRouterNavigationEvents()?.addListener('actionDispatched', event => {
82+
if (!event.actionType || !NAVIGATION_ACTION_TYPES.has(event.actionType)) {
83+
return;
84+
}
85+
reactNavigation._startIdleNavigationSpan?.({
86+
data: {
87+
action: { type: event.actionType, payload: event.payload as object | undefined },
88+
noop: false,
89+
stack: undefined,
90+
},
91+
});
92+
});
6193
pollTimer = undefined;
6294
return;
6395
}
6496

6597
if (Date.now() - startedAt >= POLL_MAX_DURATION_MS) {
6698
if (!navigationRef) {
6799
debug.warn(
68-
`${INTEGRATION_NAME} Found expo-router router-store but it does not expose a \`navigationRef\`. ` +
100+
`${INTEGRATION_NAME} Found expo-router but it does not expose a \`navigationRef\`. ` +
69101
`This likely means the installed expo-router version is incompatible with this integration.`,
70102
);
71103
} else {
@@ -85,6 +117,8 @@ export const expoRouterIntegration = (options: ExpoRouterIntegrationOptions = {}
85117
clearTimeout(pollTimer);
86118
pollTimer = undefined;
87119
}
120+
removeActionListener?.();
121+
removeActionListener = undefined;
88122
});
89123
};
90124

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

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
export interface ExpoRouterNavigationRef {
1212
current: unknown | null;
13+
getRootState?: () => unknown;
1314
}
1415

1516
export interface ExpoRouterUrlObject {
@@ -22,7 +23,7 @@ export interface ExpoRouterUrlObject {
2223

2324
export interface ExpoRouterStore {
2425
navigationRef?: ExpoRouterNavigationRef;
25-
getRouteInfo?: () => ExpoRouterUrlObject;
26+
getRouteInfo?: () => ExpoRouterUrlObject | undefined;
2627
}
2728

2829
export interface NormalizedExpoRouterRouteInfo {
@@ -42,17 +43,91 @@ export interface NormalizedExpoRouterRouteInfo {
4243
segments?: string[];
4344
}
4445

46+
export interface ExpoRouterActionDispatchedEvent {
47+
actionType?: string;
48+
payload?: unknown;
49+
}
50+
51+
export interface ExpoRouterInternalNavigationEvents {
52+
addListener: (
53+
name: 'actionDispatched',
54+
callback: (event: ExpoRouterActionDispatchedEvent) => void,
55+
) => (() => void) | undefined;
56+
}
57+
4558
/**
46-
* Returns Expo Router's internal router store, or `null` if `expo-router` is
47-
* not installed or the build does not expose the expected module path.
59+
* Returns the navigation container ref and route info of Expo Router, or `null`
60+
* if `expo-router` is not installed or does not expose the expected API.
61+
*
62+
* Expo Router 58 declares `exports`, so Metro warns about any deep path that is
63+
* missing from it. Older versions have no `exports`, so a missing deep path
64+
* there is a silent optional dependency.
4865
*/
4966
export function tryGetExpoRouterStore(): ExpoRouterStore | null {
67+
let getNavigationContainerRef: (() => ExpoRouterNavigationRef | undefined) | undefined;
68+
try {
69+
// eslint-disable-next-line @typescript-eslint/no-var-requires
70+
const expoRouter = require('expo-router') as {
71+
useNavigationContainerRef?: () => ExpoRouterNavigationRef | undefined;
72+
};
73+
// Not a React hook: it reads a module-level store, so it is safe to call outside render.
74+
getNavigationContainerRef = expoRouter?.useNavigationContainerRef;
75+
} catch {
76+
return null;
77+
}
78+
if (!getNavigationContainerRef) {
79+
return null;
80+
}
81+
const getRef = getNavigationContainerRef;
82+
const readNavigationContainerRef = (): ExpoRouterNavigationRef | undefined => {
83+
try {
84+
return getRef();
85+
} catch {
86+
return undefined;
87+
}
88+
};
89+
const getRouteInfoFromState = tryGetRouteInfoFromState();
90+
91+
return {
92+
// Before Expo Router 58 the ref is undefined until the Root Layout renders, so read it on every access.
93+
get navigationRef() {
94+
return readNavigationContainerRef();
95+
},
96+
getRouteInfo: getRouteInfoFromState
97+
? () => {
98+
const navigationRef = readNavigationContainerRef();
99+
const state = navigationRef?.current ? navigationRef.getRootState?.() : undefined;
100+
return state ? getRouteInfoFromState(state) : undefined;
101+
}
102+
: undefined,
103+
};
104+
}
105+
106+
/** `getRouteInfoFromState` exists only in Expo Router 56 and later. */
107+
function tryGetRouteInfoFromState(): ((state: unknown) => ExpoRouterUrlObject) | undefined {
108+
try {
109+
// eslint-disable-next-line @typescript-eslint/no-var-requires
110+
const mod = require('expo-router/build/global-state/getRouteInfoFromState') as {
111+
getRouteInfoFromState?: (state: unknown) => ExpoRouterUrlObject;
112+
};
113+
return mod?.getRouteInfoFromState;
114+
} catch {
115+
return undefined;
116+
}
117+
}
118+
119+
/**
120+
* Returns the internal navigation event emitter of Expo Router 58 and later, or
121+
* `null` for older versions. Expo Router 58 does not emit React Navigation's
122+
* `__unsafe_action__` event, so this is the only signal of a dispatched action.
123+
*/
124+
export function tryGetExpoRouterNavigationEvents(): ExpoRouterInternalNavigationEvents | null {
50125
try {
51126
// eslint-disable-next-line @typescript-eslint/no-var-requires
52-
const mod = require('expo-router/build/global-state/router-store') as {
53-
store?: ExpoRouterStore;
127+
const mod = require('expo-router/build/global-state/internalNavigationEvents') as {
128+
internalNavigationEvents?: ExpoRouterInternalNavigationEvents;
54129
};
55-
return mod?.store ?? null;
130+
return mod?.internalNavigationEvents ?? null;
56131
} catch {
57132
return null;
58133
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,11 @@ export const reactNavigationIntegration = ({
226226
* supply the canonical route info on every state change. Use `undefined` to clear.
227227
*/
228228
_setRouteOverrideProvider: (provider: RouteOverrideProvider | undefined) => void;
229+
/**
230+
* @internal API: start a navigation span for a dispatched action, for navigators that do not
231+
* emit `__unsafe_action__` (for example, Expo Router 58 and later).
232+
*/
233+
_startIdleNavigationSpan: (event: UnsafeAction) => void;
229234
options: ReactNavigationIntegrationOptions;
230235
} => {
231236
let navigationContainer: NavigationContainer | undefined;
@@ -896,6 +901,7 @@ export const reactNavigationIntegration = ({
896901
afterAllSetup,
897902
registerNavigationContainer,
898903
_setRouteOverrideProvider,
904+
_startIdleNavigationSpan: event => startIdleNavigationSpan(event),
899905
options: {
900906
routeChangeTimeoutMs,
901907
enableTimeToInitialDisplay,

0 commit comments

Comments
 (0)