diff --git a/CHANGELOG.md b/CHANGELOG.md index 3185190039..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)) 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..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..ff50004857 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_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_MODULE, + '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,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 +435,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 = {