diff --git a/CHANGELOG.md b/CHANGELOG.md index 090a346215..031f0775fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Auto-inject `sentry-label` from static text content at build time when `annotateReactComponents` is enabled ([#6141](https://github.com/getsentry/sentry-react-native/pull/6141)) - Respect Replay Mask boundaries when reading `sentry-label` for touch breadcrumbs ([#6142](https://github.com/getsentry/sentry-react-native/pull/6142)) - Add `textComponentNames` option to `annotateReactComponents` for custom text components ([#6169](https://github.com/getsentry/sentry-react-native/pull/6169)) +- Add first-class `expoRouterIntegration()` with auto-registration ([#6189](https://github.com/getsentry/sentry-react-native/pull/6189)) - Expose `addConsoleInstrumentationFilter` from `@sentry/core` ([#6180](https://github.com/getsentry/sentry-react-native/pull/6180)) - Expose experimental `captureSurfaceViews` option for Android Session Replay ([#6175](https://github.com/getsentry/sentry-react-native/pull/6175)) diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index bacfa15508..3957c06956 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -284,6 +284,11 @@ export interface ExpoRouter { replace?: (...args: unknown[]) => void; } +// Warning: (ae-forgotten-export) The symbol "ExpoRouterIntegrationOptions" needs to be exported by the entry point index.d.ts +// +// @public +export const expoRouterIntegration: (options?: ExpoRouterIntegrationOptions) => Integration; + // @public export const expoUpdatesListenerIntegration: () => Integration; diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index 2463ca3c2c..81734f21c5 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -120,6 +120,7 @@ export { createTimeToFullDisplay, createTimeToInitialDisplay, wrapExpoRouter, + expoRouterIntegration, wrapExpoImage, wrapExpoAsset, } from './tracing'; diff --git a/packages/core/src/js/integrations/default.ts b/packages/core/src/js/integrations/default.ts index 30dd3a156e..f91ed4a89c 100644 --- a/packages/core/src/js/integrations/default.ts +++ b/packages/core/src/js/integrations/default.ts @@ -5,7 +5,7 @@ import { browserSessionIntegration, consoleLoggingIntegration } from '@sentry/br import type { ReactNativeClientOptions } from '../options'; -import { reactNativeTracingIntegration } from '../tracing'; +import { expoRouterIntegration, reactNativeTracingIntegration } from '../tracing'; import { notWeb } from '../utils/environment'; import { appRegistryIntegration, @@ -139,6 +139,10 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ integrations.push(expoConstantsIntegration()); integrations.push(expoUpdatesListenerIntegration()); + if (hasTracingEnabled && options.enableAutoPerformanceTracing) { + integrations.push(expoRouterIntegration()); + } + if (options.spotlight && __DEV__) { const sidecarUrl = typeof options.spotlight === 'string' ? options.spotlight : undefined; integrations.push(spotlightIntegration({ sidecarUrl })); diff --git a/packages/core/src/js/tracing/expoRouterIntegration.ts b/packages/core/src/js/tracing/expoRouterIntegration.ts new file mode 100644 index 0000000000..af1f90befc --- /dev/null +++ b/packages/core/src/js/tracing/expoRouterIntegration.ts @@ -0,0 +1,108 @@ +import type { Client, Integration } from '@sentry/core'; + +import { debug } from '@sentry/core'; + +import { getReactNavigationIntegration, reactNavigationIntegration } from './reactnavigation'; + +export const INTEGRATION_NAME = 'ExpoRouter'; + +const POLL_INTERVAL_MS = 50; +const POLL_MAX_DURATION_MS = 5_000; + +interface ExpoRouterNavigationRef { + current: unknown | null; +} + +interface ExpoRouterStore { + navigationRef?: ExpoRouterNavigationRef; +} + +type ExpoRouterIntegrationOptions = Parameters[0]; + +/** + * Integration that connects Expo Router with `reactNavigationIntegration` without + * requiring the user to manually pass a `useNavigationContainerRef()` ref. + * + * @example + * ```ts + * Sentry.init({ + * integrations: [Sentry.expoRouterIntegration()], + * }); + * ``` + */ +export const expoRouterIntegration = (options: ExpoRouterIntegrationOptions = {}): Integration => { + let pollTimer: ReturnType | undefined; + + const afterAllSetup = (client: Client): void => { + const store = tryGetExpoRouterStore(); + if (!store) { + // expo-router not installed + return; + } + if (!store.navigationRef) { + debug.warn( + `${INTEGRATION_NAME} Found expo-router router-store but it does not expose a \`navigationRef\`. ` + + `This likely means the installed expo-router version is incompatible with this integration.`, + ); + return; + } + + // reuse the user's reactNavigationIntegration if they registered one manually. + // Otherwise, create and add one. + let reactNavigation = getReactNavigationIntegration(client); + if (!reactNavigation) { + reactNavigation = reactNavigationIntegration(options); + client.addIntegration(reactNavigation); + } + + const navigationRef = store.navigationRef; + + if (navigationRef.current) { + reactNavigation.registerNavigationContainer(navigationRef); + return; + } + + // Otherwise, poll until the Root Layout mounts and Expo Router sets `.current`. + const startedAt = Date.now(); + const poll = (): void => { + if (!navigationRef.current) { + if (Date.now() - startedAt >= POLL_MAX_DURATION_MS) { + debug.warn(`${INTEGRATION_NAME} Timed out waiting for Expo Router navigation container.`); + pollTimer = undefined; + return; + } + pollTimer = setTimeout(poll, POLL_INTERVAL_MS); + return; + } + + reactNavigation?.registerNavigationContainer(navigationRef); + pollTimer = undefined; + }; + + pollTimer = setTimeout(poll, POLL_INTERVAL_MS); + + client.on('close', () => { + if (pollTimer !== undefined) { + clearTimeout(pollTimer); + pollTimer = undefined; + } + }); + }; + + return { + name: INTEGRATION_NAME, + afterAllSetup, + }; +}; + +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; + }; + return mod?.store ?? null; + } catch { + return null; + } +} diff --git a/packages/core/src/js/tracing/index.ts b/packages/core/src/js/tracing/index.ts index d366885cd1..e8d4ed6ba6 100644 --- a/packages/core/src/js/tracing/index.ts +++ b/packages/core/src/js/tracing/index.ts @@ -12,6 +12,8 @@ export { reactNativeNavigationIntegration } from './reactnativenavigation'; export { wrapExpoRouter } from './expoRouter'; export type { ExpoRouter } from './expoRouter'; +export { expoRouterIntegration } from './expoRouterIntegration'; + export { wrapExpoImage } from './expoImage'; export type { ExpoImage } from './expoImage'; diff --git a/packages/core/test/integrations/defaultExpoRouter.test.ts b/packages/core/test/integrations/defaultExpoRouter.test.ts new file mode 100644 index 0000000000..c9a007d744 --- /dev/null +++ b/packages/core/test/integrations/defaultExpoRouter.test.ts @@ -0,0 +1,62 @@ +import type { Integration } from '@sentry/core'; + +import type { ReactNativeClientOptions } from '../../src/js/options'; + +import { getDefaultIntegrations } from '../../src/js/integrations/default'; +import { notWeb } from '../../src/js/utils/environment'; + +jest.mock('../../src/js/utils/environment', () => { + const actual = jest.requireActual('../../src/js/utils/environment'); + return { + ...actual, + notWeb: jest.fn(() => true), + }; +}); + +const EXPO_ROUTER_INTEGRATION_NAME = 'ExpoRouter'; + +describe('getDefaultIntegrations - expo-router integration', () => { + beforeEach(() => { + (notWeb as jest.Mock).mockReturnValue(true); + }); + + const createOptions = (overrides: Partial): ReactNativeClientOptions => { + return { + dsn: 'https://example.com/1', + enableNative: true, + ...overrides, + } as ReactNativeClientOptions; + }; + + const getNames = (options: ReactNativeClientOptions): string[] => + getDefaultIntegrations(options).map((i: Integration) => i.name); + + it('adds expoRouterIntegration when tracing and auto performance tracing are enabled', () => { + const names = getNames( + createOptions({ + tracesSampleRate: 1.0, + enableAutoPerformanceTracing: true, + }), + ); + expect(names).toContain(EXPO_ROUTER_INTEGRATION_NAME); + }); + + it('does not add expoRouterIntegration when tracing is disabled', () => { + const names = getNames( + createOptions({ + enableAutoPerformanceTracing: true, + }), + ); + expect(names).not.toContain(EXPO_ROUTER_INTEGRATION_NAME); + }); + + it('does not add expoRouterIntegration when auto performance tracing is disabled', () => { + const names = getNames( + createOptions({ + tracesSampleRate: 1.0, + enableAutoPerformanceTracing: false, + }), + ); + expect(names).not.toContain(EXPO_ROUTER_INTEGRATION_NAME); + }); +}); diff --git a/packages/core/test/tracing/expoRouterIntegration.test.ts b/packages/core/test/tracing/expoRouterIntegration.test.ts new file mode 100644 index 0000000000..d83548912d --- /dev/null +++ b/packages/core/test/tracing/expoRouterIntegration.test.ts @@ -0,0 +1,232 @@ +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'; + +interface MockNavigationContainer { + addListener: jest.Mock; + getCurrentRoute: jest.Mock; + getState: jest.Mock; +} + +function createMockNavigationContainer(): MockNavigationContainer { + return { + addListener: jest.fn(), + getCurrentRoute: jest.fn(() => ({ key: 'k', name: 'Route' })), + getState: jest.fn(() => undefined), + }; +} + +function createMockClient(): { + client: Client; + addIntegration: jest.Mock; + getIntegrationByName: jest.Mock; + on: jest.Mock; + closeHandlers: Array<() => void>; +} { + const closeHandlers: Array<() => void> = []; + const addIntegration = jest.fn(); + const getIntegrationByName = jest.fn().mockReturnValue(undefined); + const on = jest.fn((event: string, cb: () => void) => { + if (event === 'close') closeHandlers.push(cb); + }); + const client = { + addIntegration, + getIntegrationByName, + on, + } as unknown as Client; + return { client, addIntegration, getIntegrationByName, on, closeHandlers }; +} + +describe('expoRouterIntegration', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllTimers(); + jest.resetModules(); + RN_GLOBAL_OBJ.__sentry_rn_v5_registered = false; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + 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. + const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration'); + const { client, addIntegration } = createMockClient(); + + const integ = integration(); + integ.afterAllSetup?.(client); + + expect(integ.name).toBe('ExpoRouter'); + expect(addIntegration).not.toHaveBeenCalled(); + }); + }); + + describe('expo-router router-store found but navigationRef missing', () => { + it('warns and does not add the integration', () => { + jest.doMock(EXPO_ROUTER_STORE_MODULE, () => ({ store: {} }), { 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(); + + const integ = integration(); + integ.afterAllSetup?.(client); + + 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(); + jest.doMock( + EXPO_ROUTER_STORE_MODULE, + () => ({ + store: { navigationRef: { current: container } }, + }), + { virtual: true }, + ); + + const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration'); + const { client, addIntegration } = createMockClient(); + + const integ = integration(); + integ.afterAllSetup?.(client); + + expect(addIntegration).toHaveBeenCalledTimes(1); + const added = addIntegration.mock.calls[0][0]; + expect(added.name).toBe(REACT_NAVIGATION_INTEGRATION_NAME); + + expect(container.addListener).toHaveBeenCalledWith('__unsafe_action__', expect.any(Function)); + expect(container.addListener).toHaveBeenCalledWith('state', expect.any(Function)); + }); + }); + + describe('expo-router installed, navigationRef populated asynchronously', () => { + 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 }, + ); + + const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration'); + const { client, addIntegration } = createMockClient(); + + const integ = integration(); + integ.afterAllSetup?.(client); + + // nothing registered yet + expect(container.addListener).not.toHaveBeenCalled(); + expect(addIntegration).toHaveBeenCalledTimes(1); + + // tick the polling timer once before ref is populated — still no registration + jest.advanceTimersByTime(50); + expect(container.addListener).not.toHaveBeenCalled(); + + // populate the ref and tick again + navigationRef.current = container; + jest.advanceTimersByTime(50); + + expect(container.addListener).toHaveBeenCalledWith('__unsafe_action__', expect.any(Function)); + expect(container.addListener).toHaveBeenCalledWith('state', expect.any(Function)); + }); + + 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 }, + ); + + const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration'); + const { client, closeHandlers } = createMockClient(); + + const integ = integration(); + integ.afterAllSetup?.(client); + const timersAfterSetup = jest.getTimerCount(); + + jest.advanceTimersByTime(6_000); + + expect(jest.getTimerCount()).toBeLessThan(timersAfterSetup); + expect(closeHandlers.length).toBe(1); + }); + }); + + 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 }, + ); + + const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration'); + const { client, addIntegration, getIntegrationByName } = createMockClient(); + + const existingRegister = jest.fn(); + getIntegrationByName.mockImplementation((name: string) => + name === REACT_NAVIGATION_INTEGRATION_NAME + ? { name, registerNavigationContainer: existingRegister } + : undefined, + ); + + const integ = integration(); + integ.afterAllSetup?.(client); + + expect(addIntegration).not.toHaveBeenCalled(); + expect(existingRegister).toHaveBeenCalledWith({ current: container }); + }); + }); + + 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 }, + ); + + const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration'); + const { client, closeHandlers } = createMockClient(); + + const baselineTimers = jest.getTimerCount(); + const integ = integration(); + integ.afterAllSetup?.(client); + const timersAfterSetup = jest.getTimerCount(); + + // Setup schedules exactly one polling timer + expect(timersAfterSetup - baselineTimers).toBe(1); + expect(closeHandlers.length).toBe(1); + + // Simulate client.close() + closeHandlers.forEach(cb => cb()); + + // Poll timer cleared, back to baseline + expect(jest.getTimerCount()).toBe(baselineTimers); + }); + }); +}); diff --git a/samples/expo/app/_layout.tsx b/samples/expo/app/_layout.tsx index 65c549cf43..785fefd993 100644 --- a/samples/expo/app/_layout.tsx +++ b/samples/expo/app/_layout.tsx @@ -4,7 +4,7 @@ import * as Sentry from '@sentry/react-native'; import { isRunningInExpoGo } from 'expo'; import { useFonts } from 'expo-font'; import * as ImagePicker from 'expo-image-picker'; -import { SplashScreen, Stack, useNavigationContainerRef } from 'expo-router'; +import { SplashScreen, Stack } from 'expo-router'; import { useEffect } from 'react'; import { LogBox } from 'react-native'; @@ -22,10 +22,6 @@ LogBox.ignoreAllLogs(); // Prevent the splash screen from auto-hiding before asset loading is complete. SplashScreen.preventAutoHideAsync(); -const navigationIntegration = Sentry.reactNavigationIntegration({ - enableTimeToInitialDisplay: !isRunningInExpoGo(), // This is not supported in Expo Go. -}); - Sentry.init({ // Replace the example DSN below with your own DSN: dsn: SENTRY_INTERNAL_DSN, @@ -61,7 +57,9 @@ Sentry.init({ // default: [/.*/] failedRequestTargets: [/.*/], }), - navigationIntegration, + Sentry.expoRouterIntegration({ + enableTimeToInitialDisplay: !isRunningInExpoGo(), // This is not supported in Expo Go. + }), Sentry.reactNativeTracingIntegration(), Sentry.mobileReplayIntegration({ maskAllImages: true, @@ -110,14 +108,6 @@ Sentry.init({ }); function RootLayout() { - const ref = useNavigationContainerRef(); - - useEffect(() => { - if (ref) { - navigationIntegration.registerNavigationContainer(ref); - } - }, [ref]); - const [loaded, error] = useFonts({ SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'), ...FontAwesome.font,