Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

### Fixes

Expand Down
5 changes: 5 additions & 0 deletions packages/core/etc/sentry-react-native.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,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?: Partial<ExpoRouterIntegrationOptions>) => Integration;
Comment thread
alwx marked this conversation as resolved.
Outdated

// @public
export const expoUpdatesListenerIntegration: () => Integration;

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export {
createTimeToFullDisplay,
createTimeToInitialDisplay,
wrapExpoRouter,
expoRouterIntegration,
wrapExpoImage,
wrapExpoAsset,
} from './tracing';
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/js/integrations/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -139,6 +139,10 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ
integrations.push(expoConstantsIntegration());
integrations.push(expoUpdatesListenerIntegration());

if (hasTracingEnabled && options.enableAutoPerformanceTracing) {
integrations.push(expoRouterIntegration());
}
Comment thread
sentry[bot] marked this conversation as resolved.

if (options.spotlight && __DEV__) {
const sidecarUrl = typeof options.spotlight === 'string' ? options.spotlight : undefined;
integrations.push(spotlightIntegration({ sidecarUrl }));
Expand Down
103 changes: 103 additions & 0 deletions packages/core/src/js/tracing/expoRouterIntegration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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 = NonNullable<Parameters<typeof reactNavigationIntegration>[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: Partial<ExpoRouterIntegrationOptions> = {}): Integration => {
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
let pollTimer: ReturnType<typeof setTimeout> | undefined;

const setup = (client: Client): void => {
const store = tryGetExpoRouterStore();
if (!store?.navigationRef) {
// expo-router not installed
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,
setup,
};
};

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;

Check warning on line 97 in packages/core/src/js/tracing/expoRouterIntegration.ts

View check run for this annotation

@sentry/warden / warden: code-review

Reliance on private internal expo-router module path may break on version upgrades

Accessing `expo-router/build/global-state/router-store` couples the integration to an undocumented internal module path that can be renamed or restructured in any expo-router release without a semver major bump, silently breaking navigation instrumentation for all users on a new version.
};
return mod?.store ?? null;
Comment thread
sentry-warden[bot] marked this conversation as resolved.
} catch {
return null;
}
}
2 changes: 2 additions & 0 deletions packages/core/src/js/tracing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
62 changes: 62 additions & 0 deletions packages/core/test/integrations/defaultExpoRouter.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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);
});
});
Loading
Loading