From 43687e774753040616252572541836992382f380 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 21 Oct 2026 15:44:37 +0200 Subject: [PATCH 1/3] 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 --- CHANGELOG.md | 1 + packages/core/etc/sentry-react-native.api.md | 2 + .../src/js/tracing/expoRouterIntegration.ts | 23 +- .../core/src/js/tracing/expoRouterStore.ts | 63 ++++- .../core/src/js/tracing/reactnavigation.ts | 6 + .../tracing/expoRouterIntegration.test.ts | 234 +++++++++--------- .../core/test/tracing/reactnavigation.test.ts | 26 ++ .../core/test/tracing/reactnavigationutils.ts | 10 + 8 files changed, 242 insertions(+), 123 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3185190039..f43f9363da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ - Find `ios/.xcode.env` in the Expo plugin "Upload Debug Symbols to Sentry" phase when the iOS project uses Swift Package Manager instead of CocoaPods ([#6911](https://github.com/getsentry/sentry-react-native/pull/6911)) - Fix HTTP timing listeners leaking on React Native below 0.83 and when `expo/fetch` is detected, and respect the `enableHTTPTimings` option ([#6912](https://github.com/getsentry/sentry-react-native/pull/6912)) +- 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 ([#PLACEHOLDER](https://github.com/getsentry/sentry-react-native/pull/PLACEHOLDER)) - Remove the `@sentry/browser` feedback modules and the server-only `@sentry/core` modules from native bundles built on Windows. Before this fix, `includeWebFeedback: false` made these bundles fail at launch ([#6915](https://github.com/getsentry/sentry-react-native/pull/6915)) - Stop wrapping the SDK's own `RNSentry` module in `turboModuleContextIntegration` by default. Each wrapped call wrote to the native scope several times, which added CPU time to every SDK call on iOS ([#6921](https://github.com/getsentry/sentry-react-native/pull/6921)) - To wrap `RNSentry` again, add `{ name: 'RNSentry' }` to the `modules` option. diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index bde46c467f..bdadcbf33f 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -931,6 +931,7 @@ export interface ReactNativeWrapperOptions { export const reactNavigationIntegration: (input?: Partial) => Integration & { registerNavigationContainer: (navigationContainerRef: unknown) => void; _setRouteOverrideProvider: (provider: RouteOverrideProvider | undefined) => void; + _startIdleNavigationSpan: (event: UnsafeAction) => void; options: ReactNavigationIntegrationOptions; }; @@ -1337,6 +1338,7 @@ export function wrapTurboModule(name: string, module: T | null // // src/js/tracing/reactnativetracing.ts:93:3 - (ae-forgotten-export) The symbol "ReactNativeTracingState" needs to be exported by the entry point index.d.ts // src/js/tracing/reactnavigation.ts:228:3 - (ae-forgotten-export) The symbol "RouteOverrideProvider" needs to be exported by the entry point index.d.ts +// src/js/tracing/reactnavigation.ts:233:3 - (ae-forgotten-export) The symbol "UnsafeAction" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/core/src/js/tracing/expoRouterIntegration.ts b/packages/core/src/js/tracing/expoRouterIntegration.ts index 07acba3e1b..9b3c7f9e88 100644 --- a/packages/core/src/js/tracing/expoRouterIntegration.ts +++ b/packages/core/src/js/tracing/expoRouterIntegration.ts @@ -5,7 +5,11 @@ import { debug } from '@sentry/core'; import type { ExpoRouterStore, ExpoRouterUrlObject } from './expoRouterStore'; import type { RouteOverride } from './reactnavigation'; -import { buildExpoRouterTemplatedPath, tryGetExpoRouterStore } from './expoRouterStore'; +import { + buildExpoRouterTemplatedPath, + tryGetExpoRouterNavigationEvents, + tryGetExpoRouterStore, +} from './expoRouterStore'; import { getReactNavigationIntegration, reactNavigationIntegration } from './reactnavigation'; export { buildExpoRouterTemplatedPath }; @@ -30,6 +34,7 @@ export type ExpoRouterIntegrationOptions = Parameters { let pollTimer: ReturnType | undefined; + let removeActionListener: (() => void) | undefined; const afterAllSetup = (client: Client): void => { const store = tryGetExpoRouterStore(); @@ -58,6 +63,18 @@ export const expoRouterIntegration = (options: ExpoRouterIntegrationOptions = {} } reactNavigation._setRouteOverrideProvider?.(() => buildExpoRouterRouteOverride(store)); reactNavigation.registerNavigationContainer(navigationRef); + removeActionListener = tryGetExpoRouterNavigationEvents()?.addListener('actionDispatched', event => { + if (!event.actionType) { + return; + } + reactNavigation._startIdleNavigationSpan?.({ + data: { + action: { type: event.actionType, payload: event.payload as object | undefined }, + noop: false, + stack: undefined, + }, + }); + }); pollTimer = undefined; return; } @@ -65,7 +82,7 @@ export const expoRouterIntegration = (options: ExpoRouterIntegrationOptions = {} if (Date.now() - startedAt >= POLL_MAX_DURATION_MS) { if (!navigationRef) { debug.warn( - `${INTEGRATION_NAME} Found expo-router router-store but it does not expose a \`navigationRef\`. ` + + `${INTEGRATION_NAME} Found expo-router but it does not expose a \`navigationRef\`. ` + `This likely means the installed expo-router version is incompatible with this integration.`, ); } else { @@ -85,6 +102,8 @@ export const expoRouterIntegration = (options: ExpoRouterIntegrationOptions = {} clearTimeout(pollTimer); pollTimer = undefined; } + removeActionListener?.(); + removeActionListener = undefined; }); }; diff --git a/packages/core/src/js/tracing/expoRouterStore.ts b/packages/core/src/js/tracing/expoRouterStore.ts index 7b51238492..7e7fcdbac7 100644 --- a/packages/core/src/js/tracing/expoRouterStore.ts +++ b/packages/core/src/js/tracing/expoRouterStore.ts @@ -10,6 +10,7 @@ export interface ExpoRouterNavigationRef { current: unknown | null; + getRootState?: () => unknown; } export interface ExpoRouterUrlObject { @@ -22,7 +23,7 @@ export interface ExpoRouterUrlObject { export interface ExpoRouterStore { navigationRef?: ExpoRouterNavigationRef; - getRouteInfo?: () => ExpoRouterUrlObject; + getRouteInfo?: () => ExpoRouterUrlObject | undefined; } export interface NormalizedExpoRouterRouteInfo { @@ -42,17 +43,67 @@ export interface NormalizedExpoRouterRouteInfo { segments?: string[]; } +export interface ExpoRouterActionDispatchedEvent { + actionType?: string; + payload?: unknown; +} + +export interface ExpoRouterInternalNavigationEvents { + addListener: ( + name: 'actionDispatched', + callback: (event: ExpoRouterActionDispatchedEvent) => void, + ) => (() => void) | undefined; +} + /** - * 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. + * Returns the navigation container ref and route info of Expo Router, or `null` + * if `expo-router` is not installed or does not expose the expected API. + * + * Only module paths that exist in every supported Expo Router version are + * required here. Expo Router 58 declares `exports`, so Metro warns about any + * deep path that is missing from the installed version. */ 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; + const expoRouter = require('expo-router') as { + useNavigationContainerRef?: () => ExpoRouterNavigationRef | undefined; + }; + // eslint-disable-next-line @typescript-eslint/no-var-requires + const routeInfoModule = require('expo-router/build/global-state/getRouteInfoFromState') as { + getRouteInfoFromState?: (state: unknown) => ExpoRouterUrlObject; + }; + // Not a React hook: it returns a module-level ref in Expo Router 57 and 58. + const getNavigationContainerRef = expoRouter?.useNavigationContainerRef; + const getRouteInfoFromState = routeInfoModule?.getRouteInfoFromState; + if (!getNavigationContainerRef || !getRouteInfoFromState) { + return null; + } + const navigationRef = getNavigationContainerRef(); + return { + navigationRef, + getRouteInfo: () => { + const state = navigationRef?.current ? navigationRef.getRootState?.() : undefined; + return state ? getRouteInfoFromState(state) : undefined; + }, + }; + } catch { + return null; + } +} + +/** + * Returns the internal navigation event emitter of Expo Router 58 and later, or + * `null` for older versions. Expo Router 58 does not emit React Navigation's + * `__unsafe_action__` event, so this is the only signal of a dispatched action. + */ +export function tryGetExpoRouterNavigationEvents(): ExpoRouterInternalNavigationEvents | null { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require('expo-router/build/global-state/internalNavigationEvents') as { + internalNavigationEvents?: ExpoRouterInternalNavigationEvents; }; - return mod?.store ?? null; + return mod?.internalNavigationEvents ?? null; } catch { return null; } diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index dc303bb1e7..78c6b2f242 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -226,6 +226,11 @@ export const reactNavigationIntegration = ({ * supply the canonical route info on every state change. Use `undefined` to clear. */ _setRouteOverrideProvider: (provider: RouteOverrideProvider | undefined) => void; + /** + * @internal API: start a navigation span for a dispatched action, for navigators that do not + * emit `__unsafe_action__` (for example, Expo Router 58 and later). + */ + _startIdleNavigationSpan: (event: UnsafeAction) => void; options: ReactNavigationIntegrationOptions; } => { let navigationContainer: NavigationContainer | undefined; @@ -896,6 +901,7 @@ export const reactNavigationIntegration = ({ afterAllSetup, registerNavigationContainer, _setRouteOverrideProvider, + _startIdleNavigationSpan: event => startIdleNavigationSpan(event), options: { routeChangeTimeoutMs, enableTimeToInitialDisplay, diff --git a/packages/core/test/tracing/expoRouterIntegration.test.ts b/packages/core/test/tracing/expoRouterIntegration.test.ts index 4e30b2eb58..8a83528d87 100644 --- a/packages/core/test/tracing/expoRouterIntegration.test.ts +++ b/packages/core/test/tracing/expoRouterIntegration.test.ts @@ -3,7 +3,13 @@ import type { Client } from '@sentry/core'; import { INTEGRATION_NAME as REACT_NAVIGATION_INTEGRATION_NAME } from '../../src/js/tracing/reactnavigation'; import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; -const EXPO_ROUTER_STORE_MODULE = 'expo-router/build/global-state/router-store'; +const EXPO_ROUTER_ROUTE_INFO_MODULE = 'expo-router/build/global-state/getRouteInfoFromState'; +const EXPO_ROUTER_NAVIGATION_EVENTS_MODULE = 'expo-router/build/global-state/internalNavigationEvents'; + +function mockExpoRouter(navigationRef: unknown, getRouteInfoFromState: (state: unknown) => unknown = jest.fn()): void { + jest.doMock('expo-router', () => ({ useNavigationContainerRef: () => navigationRef }), { virtual: true }); + jest.doMock(EXPO_ROUTER_ROUTE_INFO_MODULE, () => ({ getRouteInfoFromState }), { virtual: true }); +} interface MockNavigationContainer { addListener: jest.Mock; @@ -54,7 +60,7 @@ describe('expoRouterIntegration', () => { describe('expo-router not installed', () => { it('is a no-op when require fails', () => { - // No mock for expo-router/build/global-state/router-store — require will throw. + // No mock for expo-router — require will throw. const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration'); const { client, addIntegration } = createMockClient(); @@ -66,9 +72,9 @@ describe('expoRouterIntegration', () => { }); }); - describe('expo-router router-store found but navigationRef missing', () => { + describe('expo-router found but navigationRef missing', () => { it('does not add the integration and warns after the timeout', () => { - jest.doMock(EXPO_ROUTER_STORE_MODULE, () => ({ store: {} }), { virtual: true }); + mockExpoRouter(undefined); const { debug } = require('@sentry/core'); const warnSpy = jest.spyOn(debug, 'warn').mockImplementation(() => undefined); @@ -90,50 +96,12 @@ describe('expoRouterIntegration', () => { warnSpy.mockRestore(); }); - - it('picks up navigationRef when it appears mid-poll (issue #6431)', () => { - const container = createMockNavigationContainer(); - const store: { navigationRef?: { current: MockNavigationContainer | null } } = {}; - jest.doMock(EXPO_ROUTER_STORE_MODULE, () => ({ store }), { virtual: true }); - - const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration'); - const { client, addIntegration } = createMockClient(); - - const integ = integration(); - integ.afterAllSetup?.(client); - - // Nothing to attach to yet — reactNavigation must not be added until we see a ref. - expect(addIntegration).not.toHaveBeenCalled(); - - // Root Layout mounts a bit later: navigationRef appears, then .current gets populated. - jest.advanceTimersByTime(200); - store.navigationRef = { current: null }; - jest.advanceTimersByTime(50); - - // navigationRef exists but .current is still null — hold off on attaching, - // otherwise a subsequent timeout would leave a non-functional integration behind. - expect(addIntegration).not.toHaveBeenCalled(); - expect(container.addListener).not.toHaveBeenCalled(); - - store.navigationRef.current = container; - jest.advanceTimersByTime(50); - - expect(addIntegration).toHaveBeenCalledTimes(1); - expect(container.addListener).toHaveBeenCalledWith('__unsafe_action__', expect.any(Function)); - expect(container.addListener).toHaveBeenCalledWith('state', expect.any(Function)); - }); }); describe('expo-router installed, navigationRef pre-populated', () => { it('registers the navigation container immediately', () => { const container = createMockNavigationContainer(); - jest.doMock( - EXPO_ROUTER_STORE_MODULE, - () => ({ - store: { navigationRef: { current: container } }, - }), - { virtual: true }, - ); + mockExpoRouter({ current: container }); const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration'); const { client, addIntegration } = createMockClient(); @@ -154,13 +122,7 @@ describe('expoRouterIntegration', () => { it('polls until ref.current is populated, then registers', () => { const container = createMockNavigationContainer(); const navigationRef: { current: MockNavigationContainer | null } = { current: null }; - jest.doMock( - EXPO_ROUTER_STORE_MODULE, - () => ({ - store: { navigationRef }, - }), - { virtual: true }, - ); + mockExpoRouter(navigationRef); const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration'); const { client, addIntegration } = createMockClient(); @@ -188,13 +150,7 @@ describe('expoRouterIntegration', () => { it('stops polling after the timeout if ref is never populated', () => { const navigationRef: { current: unknown } = { current: null }; - jest.doMock( - EXPO_ROUTER_STORE_MODULE, - () => ({ - store: { navigationRef }, - }), - { virtual: true }, - ); + mockExpoRouter(navigationRef); const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration'); const { client, addIntegration, closeHandlers } = createMockClient(); @@ -216,13 +172,7 @@ describe('expoRouterIntegration', () => { describe('user already added reactNavigationIntegration', () => { it('reuses the existing integration and does not add a duplicate', () => { const container = createMockNavigationContainer(); - jest.doMock( - EXPO_ROUTER_STORE_MODULE, - () => ({ - store: { navigationRef: { current: container } }, - }), - { virtual: true }, - ); + mockExpoRouter({ current: container }); const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration'); const { client, addIntegration, getIntegrationByName } = createMockClient(); @@ -243,70 +193,130 @@ describe('expoRouterIntegration', () => { }); describe('route override provider', () => { - it('registers a route override provider on reactNavigation derived from store.getRouteInfo', () => { + it('derives the route override from the root navigation state', () => { const container = createMockNavigationContainer(); - const getRouteInfo = jest.fn(() => ({ + const rootState = { index: 0, routes: [{ name: '(tabs)' }] }; + const getRouteInfoFromState = 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 }, + mockExpoRouter({ current: container, getRootState: () => rootState }, getRouteInfoFromState); + + const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration'); + const { client, getIntegrationByName } = createMockClient(); + const setRouteOverrideProvider = jest.fn(); + getIntegrationByName.mockImplementation((name: string) => + name === REACT_NAVIGATION_INTEGRATION_NAME + ? { name, registerNavigationContainer: jest.fn(), _setRouteOverrideProvider: setRouteOverrideProvider } + : undefined, ); + integration().afterAllSetup?.(client); + + const provider = setRouteOverrideProvider.mock.calls[0][0] as () => unknown; + expect(provider()).toEqual({ + templatedPath: '/profile/[id]', + concreteUrl: '/profile/123?utm_source=email', + params: { id: '123', utm_source: 'email' }, + }); + expect(getRouteInfoFromState).toHaveBeenCalledWith(rootState); + }); + + it('returns no route override before the navigation container mounts', () => { + const navigationRef: { current: unknown; getRootState: jest.Mock } = { + current: createMockNavigationContainer(), + getRootState: jest.fn(), + }; + const getRouteInfoFromState = jest.fn(); + mockExpoRouter(navigationRef, getRouteInfoFromState); + const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration'); - const { client, addIntegration } = createMockClient(); + const { client, getIntegrationByName } = createMockClient(); + const setRouteOverrideProvider = jest.fn(); + getIntegrationByName.mockImplementation((name: string) => + name === REACT_NAVIGATION_INTEGRATION_NAME + ? { name, registerNavigationContainer: jest.fn(), _setRouteOverrideProvider: setRouteOverrideProvider } + : undefined, + ); - const integ = integration(); - integ.afterAllSetup?.(client); + integration().afterAllSetup?.(client); + navigationRef.current = null; - const reactNavigation = addIntegration.mock.calls[0][0]; - expect(typeof reactNavigation._setRouteOverrideProvider).toBe('function'); + const provider = setRouteOverrideProvider.mock.calls[0][0] as () => unknown; + expect(provider()).toBeUndefined(); + expect(navigationRef.getRootState).not.toHaveBeenCalled(); + expect(getRouteInfoFromState).not.toHaveBeenCalled(); + }); + }); - // 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(); + describe('expo-router 58 navigation actions', () => { + function setUpWithNavigationEvents(): { + startIdleNavigationSpan: jest.Mock; + emit: (event: { actionType?: string; payload?: unknown }) => void; + removeListener: jest.Mock; + closeHandlers: Array<() => void>; + } { + const listeners: Array<(event: { actionType?: string; payload?: unknown }) => void> = []; + const removeListener = jest.fn(); + mockExpoRouter({ current: createMockNavigationContainer() }); jest.doMock( - EXPO_ROUTER_STORE_MODULE, + EXPO_ROUTER_NAVIGATION_EVENTS_MODULE, () => ({ - store: { navigationRef: { current: container }, getRouteInfo }, + internalNavigationEvents: { + addListener: (name: string, callback: (event: { actionType?: string; payload?: unknown }) => void) => { + if (name === 'actionDispatched') listeners.push(callback); + return removeListener; + }, + }, }), { 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: integration } = require('../../src/js/tracing/expoRouterIntegration'); + const { client, getIntegrationByName, closeHandlers } = createMockClient(); + const startIdleNavigationSpan = jest.fn(); + getIntegrationByName.mockImplementation((name: string) => + name === REACT_NAVIGATION_INTEGRATION_NAME + ? { name, registerNavigationContainer: jest.fn(), _startIdleNavigationSpan: startIdleNavigationSpan } + : 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; + + integration().afterAllSetup?.(client); + + return { + startIdleNavigationSpan, + emit: event => listeners.forEach(listener => listener(event)), + removeListener, + closeHandlers, }; - expect(result).toEqual({ - templatedPath: '/profile/[id]', - concreteUrl: '/profile/123?utm_source=email', - params: { id: '123', utm_source: 'email' }, + } + + it('starts a navigation span for each dispatched action', () => { + const { startIdleNavigationSpan, emit } = setUpWithNavigationEvents(); + + emit({ actionType: 'NAVIGATE', payload: { name: 'profile' } }); + + expect(startIdleNavigationSpan).toHaveBeenCalledWith({ + data: { action: { type: 'NAVIGATE', payload: { name: 'profile' } }, noop: false, stack: undefined }, }); - expect(getRouteInfo).toHaveBeenCalled(); - // unused — silence ts-noemit warnings for captured `setProvider` - void setProvider; + }); + + it('ignores events without an action type', () => { + const { startIdleNavigationSpan, emit } = setUpWithNavigationEvents(); + + emit({}); + + expect(startIdleNavigationSpan).not.toHaveBeenCalled(); + }); + + it('removes the listener when the client closes', () => { + const { removeListener, closeHandlers } = setUpWithNavigationEvents(); + + closeHandlers.forEach(cb => cb()); + + expect(removeListener).toHaveBeenCalledTimes(1); }); }); @@ -329,13 +339,7 @@ describe('expoRouterIntegration', () => { describe('cleanup', () => { it('clears the polling timer when the client closes', () => { const navigationRef: { current: unknown } = { current: null }; - jest.doMock( - EXPO_ROUTER_STORE_MODULE, - () => ({ - store: { navigationRef }, - }), - { virtual: true }, - ); + mockExpoRouter(navigationRef); const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration'); const { client, closeHandlers } = createMockClient(); diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index c04743968d..e51bf25ba5 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -267,6 +267,32 @@ describe('ReactNavigationInstrumentation', () => { ); }); + test('transaction sent on navigation started through _startIdleNavigationSpan', async () => { + setupTestClient(); + jest.runOnlyPendingTimers(); // Flush the init transaction + + mockNavigation.navigateToNewScreenWithoutUnsafeAction(); + jest.runOnlyPendingTimers(); // Flush the navigation transaction + + await client.flush(); + + expect(client.event).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'New Screen', + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + data: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_ROUTE_NAME]: 'New Screen', + [SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_NAME]: 'Initial Screen', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }), + }), + }), + }), + ); + }); + describe('pendingExpoRouterNavigation hand-off', () => { afterEach(() => { clearPendingExpoRouterNavigation(); diff --git a/packages/core/test/tracing/reactnavigationutils.ts b/packages/core/test/tracing/reactnavigationutils.ts index 48609d1109..d05d26b1e1 100644 --- a/packages/core/test/tracing/reactnavigationutils.ts +++ b/packages/core/test/tracing/reactnavigationutils.ts @@ -50,6 +50,16 @@ export function createMockNavigationAndAttachTo(sut: ReturnType { + sut._startIdleNavigationSpan(navigationAction); + mockedNavigationContained.currentRoute = { + key: 'new_screen', + name: 'New Screen', + }; + mockedNavigationContained.listeners['state']({ + // this object is not used by the instrumentation + }); + }, navigateToSecondScreen: () => { mockedNavigationContained.listeners['__unsafe_action__'](navigationAction); mockedNavigationContained.currentRoute = { From 84f12a79b84e814db6f954368df0b3bd7d5c9ca0 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 21 Oct 2026 15:46:19 +0200 Subject: [PATCH 2/3] docs: Add the PR number to the Expo Router 58 changelog entry Replace the placeholder in the CHANGELOG entry with the PR number, #6923. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f43f9363da..c9b7f3c504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,7 @@ - Find `ios/.xcode.env` in the Expo plugin "Upload Debug Symbols to Sentry" phase when the iOS project uses Swift Package Manager instead of CocoaPods ([#6911](https://github.com/getsentry/sentry-react-native/pull/6911)) - Fix HTTP timing listeners leaking on React Native below 0.83 and when `expo/fetch` is detected, and respect the `enableHTTPTimings` option ([#6912](https://github.com/getsentry/sentry-react-native/pull/6912)) -- 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 ([#PLACEHOLDER](https://github.com/getsentry/sentry-react-native/pull/PLACEHOLDER)) +- 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)) - Remove the `@sentry/browser` feedback modules and the server-only `@sentry/core` modules from native bundles built on Windows. Before this fix, `includeWebFeedback: false` made these bundles fail at launch ([#6915](https://github.com/getsentry/sentry-react-native/pull/6915)) - Stop wrapping the SDK's own `RNSentry` module in `turboModuleContextIntegration` by default. Each wrapped call wrote to the native scope several times, which added CPU time to every SDK call on iOS ([#6921](https://github.com/getsentry/sentry-react-native/pull/6921)) - To wrap `RNSentry` again, add `{ name: 'RNSentry' }` to the `modules` option. From 1246c96c681e550427ee95a46998f18793743571 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Fri, 23 Oct 2026 14:00:22 +0200 Subject: [PATCH 3/3] 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. --- CHANGELOG.md | 5 +- .../core/src/js/tracing/expoRouterStore.ts | 62 ++++++++---- .../tracing/expoRouterIntegration.test.ts | 96 +++++++++++++++++++ 3 files changed, 143 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9b7f3c504..13e707dcb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## Unreleased +### Fixes + +- 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)) + ### Dependencies - Bump CLI from v3.8.1 to v3.8.2 ([#6933](https://github.com/getsentry/sentry-react-native/pull/6933)) @@ -33,7 +37,6 @@ - Find `ios/.xcode.env` in the Expo plugin "Upload Debug Symbols to Sentry" phase when the iOS project uses Swift Package Manager instead of CocoaPods ([#6911](https://github.com/getsentry/sentry-react-native/pull/6911)) - Fix HTTP timing listeners leaking on React Native below 0.83 and when `expo/fetch` is detected, and respect the `enableHTTPTimings` option ([#6912](https://github.com/getsentry/sentry-react-native/pull/6912)) -- 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)) - Remove the `@sentry/browser` feedback modules and the server-only `@sentry/core` modules from native bundles built on Windows. Before this fix, `includeWebFeedback: false` made these bundles fail at launch ([#6915](https://github.com/getsentry/sentry-react-native/pull/6915)) - Stop wrapping the SDK's own `RNSentry` module in `turboModuleContextIntegration` by default. Each wrapped call wrote to the native scope several times, which added CPU time to every SDK call on iOS ([#6921](https://github.com/getsentry/sentry-react-native/pull/6921)) - To wrap `RNSentry` again, add `{ name: 'RNSentry' }` to the `modules` option. diff --git a/packages/core/src/js/tracing/expoRouterStore.ts b/packages/core/src/js/tracing/expoRouterStore.ts index 7e7fcdbac7..cfa88bd066 100644 --- a/packages/core/src/js/tracing/expoRouterStore.ts +++ b/packages/core/src/js/tracing/expoRouterStore.ts @@ -59,36 +59,60 @@ export interface ExpoRouterInternalNavigationEvents { * Returns the navigation container ref and route info of Expo Router, or `null` * if `expo-router` is not installed or does not expose the expected API. * - * Only module paths that exist in every supported Expo Router version are - * required here. Expo Router 58 declares `exports`, so Metro warns about any - * deep path that is missing from the installed version. + * Expo Router 58 declares `exports`, so Metro warns about any deep path that is + * missing from it. Older versions have no `exports`, so a missing deep path + * there is a silent optional dependency. */ export function tryGetExpoRouterStore(): ExpoRouterStore | null { + let getNavigationContainerRef: (() => ExpoRouterNavigationRef | undefined) | undefined; try { // eslint-disable-next-line @typescript-eslint/no-var-requires const expoRouter = require('expo-router') as { useNavigationContainerRef?: () => ExpoRouterNavigationRef | undefined; }; + // Not a React hook: it reads a module-level store, so it is safe to call outside render. + getNavigationContainerRef = expoRouter?.useNavigationContainerRef; + } catch { + return null; + } + if (!getNavigationContainerRef) { + return null; + } + const getRef = getNavigationContainerRef; + const readNavigationContainerRef = (): ExpoRouterNavigationRef | undefined => { + try { + return getRef(); + } catch { + return undefined; + } + }; + const getRouteInfoFromState = tryGetRouteInfoFromState(); + + return { + // Before Expo Router 58 the ref is undefined until the Root Layout renders, so read it on every access. + get navigationRef() { + return readNavigationContainerRef(); + }, + getRouteInfo: getRouteInfoFromState + ? () => { + const navigationRef = readNavigationContainerRef(); + const state = navigationRef?.current ? navigationRef.getRootState?.() : undefined; + return state ? getRouteInfoFromState(state) : undefined; + } + : undefined, + }; +} + +/** `getRouteInfoFromState` exists only in Expo Router 56 and later. */ +function tryGetRouteInfoFromState(): ((state: unknown) => ExpoRouterUrlObject) | undefined { + try { // eslint-disable-next-line @typescript-eslint/no-var-requires - const routeInfoModule = require('expo-router/build/global-state/getRouteInfoFromState') as { + const mod = require('expo-router/build/global-state/getRouteInfoFromState') as { getRouteInfoFromState?: (state: unknown) => ExpoRouterUrlObject; }; - // Not a React hook: it returns a module-level ref in Expo Router 57 and 58. - const getNavigationContainerRef = expoRouter?.useNavigationContainerRef; - const getRouteInfoFromState = routeInfoModule?.getRouteInfoFromState; - if (!getNavigationContainerRef || !getRouteInfoFromState) { - return null; - } - const navigationRef = getNavigationContainerRef(); - return { - navigationRef, - getRouteInfo: () => { - const state = navigationRef?.current ? navigationRef.getRootState?.() : undefined; - return state ? getRouteInfoFromState(state) : undefined; - }, - }; + return mod?.getRouteInfoFromState; } catch { - return null; + return undefined; } } diff --git a/packages/core/test/tracing/expoRouterIntegration.test.ts b/packages/core/test/tracing/expoRouterIntegration.test.ts index 8a83528d87..ff50004857 100644 --- a/packages/core/test/tracing/expoRouterIntegration.test.ts +++ b/packages/core/test/tracing/expoRouterIntegration.test.ts @@ -98,6 +98,102 @@ describe('expoRouterIntegration', () => { }); }); + describe('expo-router 56 and 57, navigationRef set when the Root Layout renders', () => { + it('picks up navigationRef when it appears mid-poll (issue #6431)', () => { + const container = createMockNavigationContainer(); + const store: { navigationRef?: { current: MockNavigationContainer | null } } = {}; + jest.doMock('expo-router', () => ({ useNavigationContainerRef: () => store.navigationRef }), { + virtual: true, + }); + + const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration'); + const { client, addIntegration } = createMockClient(); + + const integ = integration(); + integ.afterAllSetup?.(client); + + expect(addIntegration).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(200); + store.navigationRef = { current: null }; + jest.advanceTimersByTime(50); + + // The ref exists but `.current` is still null, so a later timeout must not leave a + // non-functional integration behind. + expect(addIntegration).not.toHaveBeenCalled(); + expect(container.addListener).not.toHaveBeenCalled(); + + store.navigationRef.current = container; + jest.advanceTimersByTime(50); + + expect(addIntegration).toHaveBeenCalledTimes(1); + expect(container.addListener).toHaveBeenCalledWith('__unsafe_action__', expect.any(Function)); + expect(container.addListener).toHaveBeenCalledWith('state', expect.any(Function)); + }); + }); + + describe('expo-router 55 and older, getRouteInfoFromState missing', () => { + it('registers the navigation container without a route override', () => { + const container = createMockNavigationContainer(); + jest.doMock('expo-router', () => ({ useNavigationContainerRef: () => ({ current: container }) }), { + virtual: true, + }); + jest.doMock( + EXPO_ROUTER_ROUTE_INFO_MODULE, + () => { + throw new Error(`Cannot find module '${EXPO_ROUTER_ROUTE_INFO_MODULE}'`); + }, + { virtual: true }, + ); + + const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration'); + const { client, getIntegrationByName } = createMockClient(); + const registerNavigationContainer = jest.fn(); + const setRouteOverrideProvider = jest.fn(); + getIntegrationByName.mockImplementation((name: string) => + name === REACT_NAVIGATION_INTEGRATION_NAME + ? { name, registerNavigationContainer, _setRouteOverrideProvider: setRouteOverrideProvider } + : undefined, + ); + + integration().afterAllSetup?.(client); + + expect(registerNavigationContainer).toHaveBeenCalledWith({ current: container }); + const provider = setRouteOverrideProvider.mock.calls[0][0] as () => unknown; + expect(provider()).toBeUndefined(); + }); + }); + + describe('useNavigationContainerRef throws', () => { + it('keeps polling without throwing and warns after the timeout', () => { + jest.doMock( + 'expo-router', + () => ({ + useNavigationContainerRef: () => { + throw new Error('Invalid hook call'); + }, + }), + { virtual: true }, + ); + + const { debug } = require('@sentry/core'); + const warnSpy = jest.spyOn(debug, 'warn').mockImplementation(() => undefined); + + const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration'); + const { client, addIntegration } = createMockClient(); + + expect(() => { + integration().afterAllSetup?.(client); + jest.advanceTimersByTime(6_000); + }).not.toThrow(); + + expect(addIntegration).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('navigationRef')); + + warnSpy.mockRestore(); + }); + }); + describe('expo-router installed, navigationRef pre-populated', () => { it('registers the navigation container immediately', () => { const container = createMockNavigationContainer();