Skip to content

Commit b86481d

Browse files
authored
Merge branch 'main' into antonis/expose-pause-resume-app-hang-tracking
2 parents b3ed799 + 5125c43 commit b86481d

9 files changed

Lines changed: 420 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
- 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))
1818
- Respect Replay Mask boundaries when reading `sentry-label` for touch breadcrumbs ([#6142](https://github.com/getsentry/sentry-react-native/pull/6142))
1919
- Add `textComponentNames` option to `annotateReactComponents` for custom text components ([#6169](https://github.com/getsentry/sentry-react-native/pull/6169))
20+
- Add first-class `expoRouterIntegration()` with auto-registration ([#6189](https://github.com/getsentry/sentry-react-native/pull/6189))
2021
- Expose `addConsoleInstrumentationFilter` from `@sentry/core` ([#6180](https://github.com/getsentry/sentry-react-native/pull/6180))
2122
- Expose experimental `captureSurfaceViews` option for Android Session Replay ([#6175](https://github.com/getsentry/sentry-react-native/pull/6175))
2223
- Expose `pauseAppHangTracking` and `resumeAppHangTracking` APIs on iOS ([#6192](https://github.com/getsentry/sentry-react-native/pull/6192))

packages/core/etc/sentry-react-native.api.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,11 @@ export interface ExpoRouter {
284284
replace?: (...args: unknown[]) => void;
285285
}
286286

287+
// Warning: (ae-forgotten-export) The symbol "ExpoRouterIntegrationOptions" needs to be exported by the entry point index.d.ts
288+
//
289+
// @public
290+
export const expoRouterIntegration: (options?: ExpoRouterIntegrationOptions) => Integration;
291+
287292
// @public
288293
export const expoUpdatesListenerIntegration: () => Integration;
289294

packages/core/src/js/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ export {
131131
createTimeToFullDisplay,
132132
createTimeToInitialDisplay,
133133
wrapExpoRouter,
134+
expoRouterIntegration,
134135
wrapExpoImage,
135136
wrapExpoAsset,
136137
} from './tracing';

packages/core/src/js/integrations/default.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { browserSessionIntegration, consoleLoggingIntegration } from '@sentry/br
55

66
import type { ReactNativeClientOptions } from '../options';
77

8-
import { reactNativeTracingIntegration } from '../tracing';
8+
import { expoRouterIntegration, reactNativeTracingIntegration } from '../tracing';
99
import { notWeb } from '../utils/environment';
1010
import {
1111
appRegistryIntegration,
@@ -139,6 +139,10 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ
139139
integrations.push(expoConstantsIntegration());
140140
integrations.push(expoUpdatesListenerIntegration());
141141

142+
if (hasTracingEnabled && options.enableAutoPerformanceTracing) {
143+
integrations.push(expoRouterIntegration());
144+
}
145+
142146
if (options.spotlight && __DEV__) {
143147
const sidecarUrl = typeof options.spotlight === 'string' ? options.spotlight : undefined;
144148
integrations.push(spotlightIntegration({ sidecarUrl }));
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import type { Client, Integration } from '@sentry/core';
2+
3+
import { debug } from '@sentry/core';
4+
5+
import { getReactNavigationIntegration, reactNavigationIntegration } from './reactnavigation';
6+
7+
export const INTEGRATION_NAME = 'ExpoRouter';
8+
9+
const POLL_INTERVAL_MS = 50;
10+
const POLL_MAX_DURATION_MS = 5_000;
11+
12+
interface ExpoRouterNavigationRef {
13+
current: unknown | null;
14+
}
15+
16+
interface ExpoRouterStore {
17+
navigationRef?: ExpoRouterNavigationRef;
18+
}
19+
20+
type ExpoRouterIntegrationOptions = Parameters<typeof reactNavigationIntegration>[0];
21+
22+
/**
23+
* Integration that connects Expo Router with `reactNavigationIntegration` without
24+
* requiring the user to manually pass a `useNavigationContainerRef()` ref.
25+
*
26+
* @example
27+
* ```ts
28+
* Sentry.init({
29+
* integrations: [Sentry.expoRouterIntegration()],
30+
* });
31+
* ```
32+
*/
33+
export const expoRouterIntegration = (options: ExpoRouterIntegrationOptions = {}): Integration => {
34+
let pollTimer: ReturnType<typeof setTimeout> | undefined;
35+
36+
const afterAllSetup = (client: Client): void => {
37+
const store = tryGetExpoRouterStore();
38+
if (!store) {
39+
// expo-router not installed
40+
return;
41+
}
42+
if (!store.navigationRef) {
43+
debug.warn(
44+
`${INTEGRATION_NAME} Found expo-router router-store but it does not expose a \`navigationRef\`. ` +
45+
`This likely means the installed expo-router version is incompatible with this integration.`,
46+
);
47+
return;
48+
}
49+
50+
// reuse the user's reactNavigationIntegration if they registered one manually.
51+
// Otherwise, create and add one.
52+
let reactNavigation = getReactNavigationIntegration(client);
53+
if (!reactNavigation) {
54+
reactNavigation = reactNavigationIntegration(options);
55+
client.addIntegration(reactNavigation);
56+
}
57+
58+
const navigationRef = store.navigationRef;
59+
60+
if (navigationRef.current) {
61+
reactNavigation.registerNavigationContainer(navigationRef);
62+
return;
63+
}
64+
65+
// Otherwise, poll until the Root Layout mounts and Expo Router sets `.current`.
66+
const startedAt = Date.now();
67+
const poll = (): void => {
68+
if (!navigationRef.current) {
69+
if (Date.now() - startedAt >= POLL_MAX_DURATION_MS) {
70+
debug.warn(`${INTEGRATION_NAME} Timed out waiting for Expo Router navigation container.`);
71+
pollTimer = undefined;
72+
return;
73+
}
74+
pollTimer = setTimeout(poll, POLL_INTERVAL_MS);
75+
return;
76+
}
77+
78+
reactNavigation?.registerNavigationContainer(navigationRef);
79+
pollTimer = undefined;
80+
};
81+
82+
pollTimer = setTimeout(poll, POLL_INTERVAL_MS);
83+
84+
client.on('close', () => {
85+
if (pollTimer !== undefined) {
86+
clearTimeout(pollTimer);
87+
pollTimer = undefined;
88+
}
89+
});
90+
};
91+
92+
return {
93+
name: INTEGRATION_NAME,
94+
afterAllSetup,
95+
};
96+
};
97+
98+
function tryGetExpoRouterStore(): ExpoRouterStore | null {
99+
try {
100+
// eslint-disable-next-line @typescript-eslint/no-var-requires
101+
const mod = require('expo-router/build/global-state/router-store') as {
102+
store?: ExpoRouterStore;
103+
};
104+
return mod?.store ?? null;
105+
} catch {
106+
return null;
107+
}
108+
}

packages/core/src/js/tracing/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export { reactNativeNavigationIntegration } from './reactnativenavigation';
1212
export { wrapExpoRouter } from './expoRouter';
1313
export type { ExpoRouter } from './expoRouter';
1414

15+
export { expoRouterIntegration } from './expoRouterIntegration';
16+
1517
export { wrapExpoImage } from './expoImage';
1618
export type { ExpoImage } from './expoImage';
1719

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { Integration } from '@sentry/core';
2+
3+
import type { ReactNativeClientOptions } from '../../src/js/options';
4+
5+
import { getDefaultIntegrations } from '../../src/js/integrations/default';
6+
import { notWeb } from '../../src/js/utils/environment';
7+
8+
jest.mock('../../src/js/utils/environment', () => {
9+
const actual = jest.requireActual('../../src/js/utils/environment');
10+
return {
11+
...actual,
12+
notWeb: jest.fn(() => true),
13+
};
14+
});
15+
16+
const EXPO_ROUTER_INTEGRATION_NAME = 'ExpoRouter';
17+
18+
describe('getDefaultIntegrations - expo-router integration', () => {
19+
beforeEach(() => {
20+
(notWeb as jest.Mock).mockReturnValue(true);
21+
});
22+
23+
const createOptions = (overrides: Partial<ReactNativeClientOptions>): ReactNativeClientOptions => {
24+
return {
25+
dsn: 'https://example.com/1',
26+
enableNative: true,
27+
...overrides,
28+
} as ReactNativeClientOptions;
29+
};
30+
31+
const getNames = (options: ReactNativeClientOptions): string[] =>
32+
getDefaultIntegrations(options).map((i: Integration) => i.name);
33+
34+
it('adds expoRouterIntegration when tracing and auto performance tracing are enabled', () => {
35+
const names = getNames(
36+
createOptions({
37+
tracesSampleRate: 1.0,
38+
enableAutoPerformanceTracing: true,
39+
}),
40+
);
41+
expect(names).toContain(EXPO_ROUTER_INTEGRATION_NAME);
42+
});
43+
44+
it('does not add expoRouterIntegration when tracing is disabled', () => {
45+
const names = getNames(
46+
createOptions({
47+
enableAutoPerformanceTracing: true,
48+
}),
49+
);
50+
expect(names).not.toContain(EXPO_ROUTER_INTEGRATION_NAME);
51+
});
52+
53+
it('does not add expoRouterIntegration when auto performance tracing is disabled', () => {
54+
const names = getNames(
55+
createOptions({
56+
tracesSampleRate: 1.0,
57+
enableAutoPerformanceTracing: false,
58+
}),
59+
);
60+
expect(names).not.toContain(EXPO_ROUTER_INTEGRATION_NAME);
61+
});
62+
});

0 commit comments

Comments
 (0)