diff --git a/CHANGELOG.md b/CHANGELOG.md index df72146ed5..cc5208173a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Apply `screenshot` masking options to Android error and feedback screenshots ([#6930](https://github.com/getsentry/sentry-react-native/pull/6930)) - Persist fatal JS error envelopes to disk on Android before the process exits ([#6937](https://github.com/getsentry/sentry-react-native/pull/6937)) - 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. +- 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)) ### Internal 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..f60fa2749d 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 }; @@ -15,6 +19,21 @@ export const INTEGRATION_NAME = 'ExpoRouter'; const POLL_INTERVAL_MS = 50; const POLL_MAX_DURATION_MS = 5_000; +// Expo Router 58 emits `actionDispatched` for every reducer action, including internal +// ones such as `ROUTE_NAMES_CHANGED`, `PRELOAD`, and `SET_PARAMS`. Only user navigations +// start a navigation span; the rest would ship as empty "Route Change" transactions. +const NAVIGATION_ACTION_TYPES = new Set([ + 'NAVIGATE', + 'PUSH', + 'REPLACE', + 'GO_BACK', + 'POP', + 'POP_TO', + 'POP_TO_TOP', + 'JUMP_TO', + 'RESET', +]); + export type ExpoRouterIntegrationOptions = Parameters[0]; /** @@ -30,6 +49,7 @@ export type ExpoRouterIntegrationOptions = Parameters { let pollTimer: ReturnType | undefined; + let removeActionListener: (() => void) | undefined; const afterAllSetup = (client: Client): void => { const store = tryGetExpoRouterStore(); @@ -58,6 +78,18 @@ export const expoRouterIntegration = (options: ExpoRouterIntegrationOptions = {} } reactNavigation._setRouteOverrideProvider?.(() => buildExpoRouterRouteOverride(store)); reactNavigation.registerNavigationContainer(navigationRef); + removeActionListener = tryGetExpoRouterNavigationEvents()?.addListener('actionDispatched', event => { + if (!event.actionType || !NAVIGATION_ACTION_TYPES.has(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 +97,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 +117,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..cfa88bd066 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,91 @@ 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. + * + * 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 mod = require('expo-router/build/global-state/getRouteInfoFromState') as { + getRouteInfoFromState?: (state: unknown) => ExpoRouterUrlObject; + }; + return mod?.getRouteInfoFromState; + } catch { + return undefined; + } +} + +/** + * 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/router-store') as { - store?: ExpoRouterStore; + 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..11fcbd651d 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,11 +96,15 @@ describe('expoRouterIntegration', () => { warnSpy.mockRestore(); }); + }); + 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_STORE_MODULE, () => ({ store }), { virtual: true }); + jest.doMock('expo-router', () => ({ useNavigationContainerRef: () => store.navigationRef }), { + virtual: true, + }); const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration'); const { client, addIntegration } = createMockClient(); @@ -102,16 +112,14 @@ describe('expoRouterIntegration', () => { 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. + // 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(); @@ -124,17 +132,73 @@ describe('expoRouterIntegration', () => { }); }); - describe('expo-router installed, navigationRef pre-populated', () => { - it('registers the navigation container immediately', () => { + 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_STORE_MODULE, + 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', () => ({ - store: { navigationRef: { current: container } }, + 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(); + mockExpoRouter({ current: container }); + const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration'); const { client, addIntegration } = createMockClient(); @@ -154,13 +218,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 +246,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 +268,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 +289,152 @@ 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.each(['ROUTE_NAMES_CHANGED', 'PRELOAD', 'SET_PARAMS', 'REMOVE_ROUTES', 'OPEN_DRAWER'])( + 'ignores the internal %s action', + actionType => { + const { startIdleNavigationSpan, emit } = setUpWithNavigationEvents(); + + emit({ actionType, payload: { name: 'profile' } }); + + expect(startIdleNavigationSpan).not.toHaveBeenCalled(); + }, + ); + + it.each(['PUSH', 'REPLACE', 'GO_BACK', 'POP', 'POP_TO', 'POP_TO_TOP', 'JUMP_TO', 'RESET'])( + 'starts a navigation span for the %s action', + actionType => { + const { startIdleNavigationSpan, emit } = setUpWithNavigationEvents(); + + emit({ actionType }); + + expect(startIdleNavigationSpan).toHaveBeenCalledTimes(1); + }, + ); + + it('removes the listener when the client closes', () => { + const { removeListener, closeHandlers } = setUpWithNavigationEvents(); + + closeHandlers.forEach(cb => cb()); + + expect(removeListener).toHaveBeenCalledTimes(1); }); }); @@ -329,13 +457,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 = {