Skip to content

Commit 4b5b5d8

Browse files
alwxclaude
andauthored
fix(tracing): Retry expoRouterIntegration navigationRef lookup (#6451)
* fix(tracing): Retry expoRouterIntegration navigationRef lookup Poll for both `store.navigationRef` and `navigationRef.current` to appear, instead of bailing on the first synchronous check. `Sentry.init()` runs at module eval time (as the docs recommend), which is before Expo Router's Root Layout mounts and populates `store.navigationRef`. Fixes #6431 * docs(changelog): Add entry for #6451 * fix(tracing): Defer expoRouter integration attach until registration is possible Only attach `reactNavigationIntegration` when `navigationRef.current` is ready to register, so a poll timeout can never leave a non-functional integration on the client. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 32efa23 commit 4b5b5d8

3 files changed

Lines changed: 80 additions & 37 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
- Fix `TypeError` when `showFeedbackForm`/`showFeedbackButton`/`showScreenshotButton` is called before `FeedbackFormProvider` mounts ([#6435](https://github.com/getsentry/sentry-react-native/pull/6435))
3737
- Fix orphaned TTID/TTFD spans in the trace view ([#6437](https://github.com/getsentry/sentry-react-native/pull/6437))
3838
- Fix iOS retain cycle in `RNSentryOnDrawReporterView` leaking TTID/TTFD reporter views and their frame-tracker listeners ([#6449](https://github.com/getsentry/sentry-react-native/pull/6449))
39+
- Fix `expoRouterIntegration` bailing out on cold start when `Sentry.init()` runs before Expo Router's Root Layout mounts — the `store.navigationRef` check is now retried until both the ref and its `.current` are populated ([#6451](https://github.com/getsentry/sentry-react-native/pull/6451))
3940
- Fix `reactNavigationIntegration` reading a stale route from an override provider (e.g. `expoRouterIntegration`), causing navigation transactions to be named/attributed for the previous route ([#6458](https://github.com/getsentry/sentry-react-native/pull/6458))
4041

4142
### Internal

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

Lines changed: 32 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -37,49 +37,48 @@ export const expoRouterIntegration = (options: ExpoRouterIntegrationOptions = {}
3737
// expo-router not installed
3838
return;
3939
}
40-
if (!store.navigationRef) {
41-
debug.warn(
42-
`${INTEGRATION_NAME} Found expo-router router-store but it does not expose a \`navigationRef\`. ` +
43-
`This likely means the installed expo-router version is incompatible with this integration.`,
44-
);
45-
return;
46-
}
47-
48-
// reuse the user's reactNavigationIntegration if they registered one manually.
49-
// Otherwise, create and add one.
50-
let reactNavigation = getReactNavigationIntegration(client);
51-
if (!reactNavigation) {
52-
reactNavigation = reactNavigationIntegration(options);
53-
client.addIntegration(reactNavigation);
54-
}
55-
56-
const navigationRef = store.navigationRef;
57-
58-
reactNavigation._setRouteOverrideProvider?.(() => buildExpoRouterRouteOverride(store));
59-
60-
if (navigationRef.current) {
61-
reactNavigation.registerNavigationContainer(navigationRef);
62-
return;
63-
}
6440

65-
// Otherwise, poll until the Root Layout mounts and Expo Router sets `.current`.
41+
// `Sentry.init()` is typically called at module eval time (as the docs recommend),
42+
// which runs before Expo Router's Root Layout mounts. Both `store.navigationRef` and
43+
// `navigationRef.current` may still be undefined here — poll for both to appear.
44+
// Defer adding `reactNavigationIntegration` until we can also register it, so a
45+
// timeout can never leave a non-functional integration attached to the client.
6646
const startedAt = Date.now();
47+
6748
const poll = (): void => {
68-
if (!navigationRef.current) {
69-
if (Date.now() - startedAt >= POLL_MAX_DURATION_MS) {
49+
const navigationRef = store.navigationRef;
50+
51+
if (navigationRef?.current) {
52+
// Reuse the user's reactNavigationIntegration if they registered one manually.
53+
// Otherwise, create and add one.
54+
const existing = getReactNavigationIntegration(client);
55+
const reactNavigation = existing ?? reactNavigationIntegration(options);
56+
if (!existing) {
57+
client.addIntegration(reactNavigation);
58+
}
59+
reactNavigation._setRouteOverrideProvider?.(() => buildExpoRouterRouteOverride(store));
60+
reactNavigation.registerNavigationContainer(navigationRef);
61+
pollTimer = undefined;
62+
return;
63+
}
64+
65+
if (Date.now() - startedAt >= POLL_MAX_DURATION_MS) {
66+
if (!navigationRef) {
67+
debug.warn(
68+
`${INTEGRATION_NAME} Found expo-router router-store but it does not expose a \`navigationRef\`. ` +
69+
`This likely means the installed expo-router version is incompatible with this integration.`,
70+
);
71+
} else {
7072
debug.warn(`${INTEGRATION_NAME} Timed out waiting for Expo Router navigation container.`);
71-
pollTimer = undefined;
72-
return;
7373
}
74-
pollTimer = setTimeout(poll, POLL_INTERVAL_MS);
74+
pollTimer = undefined;
7575
return;
7676
}
7777

78-
reactNavigation?.registerNavigationContainer(navigationRef);
79-
pollTimer = undefined;
78+
pollTimer = setTimeout(poll, POLL_INTERVAL_MS);
8079
};
8180

82-
pollTimer = setTimeout(poll, POLL_INTERVAL_MS);
81+
poll();
8382

8483
client.on('close', () => {
8584
if (pollTimer !== undefined) {

packages/core/test/tracing/expoRouterIntegration.test.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ describe('expoRouterIntegration', () => {
6767
});
6868

6969
describe('expo-router router-store found but navigationRef missing', () => {
70-
it('warns and does not add the integration', () => {
70+
it('does not add the integration and warns after the timeout', () => {
7171
jest.doMock(EXPO_ROUTER_STORE_MODULE, () => ({ store: {} }), { virtual: true });
7272

7373
const { debug } = require('@sentry/core');
@@ -79,11 +79,49 @@ describe('expoRouterIntegration', () => {
7979
const integ = integration();
8080
integ.afterAllSetup?.(client);
8181

82+
// No warning yet — we keep polling in case the ref shows up later.
83+
expect(warnSpy).not.toHaveBeenCalled();
84+
expect(addIntegration).not.toHaveBeenCalled();
85+
86+
jest.advanceTimersByTime(6_000);
87+
8288
expect(addIntegration).not.toHaveBeenCalled();
8389
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('navigationRef'));
8490

8591
warnSpy.mockRestore();
8692
});
93+
94+
it('picks up navigationRef when it appears mid-poll (issue #6431)', () => {
95+
const container = createMockNavigationContainer();
96+
const store: { navigationRef?: { current: MockNavigationContainer | null } } = {};
97+
jest.doMock(EXPO_ROUTER_STORE_MODULE, () => ({ store }), { virtual: true });
98+
99+
const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration');
100+
const { client, addIntegration } = createMockClient();
101+
102+
const integ = integration();
103+
integ.afterAllSetup?.(client);
104+
105+
// Nothing to attach to yet — reactNavigation must not be added until we see a ref.
106+
expect(addIntegration).not.toHaveBeenCalled();
107+
108+
// Root Layout mounts a bit later: navigationRef appears, then .current gets populated.
109+
jest.advanceTimersByTime(200);
110+
store.navigationRef = { current: null };
111+
jest.advanceTimersByTime(50);
112+
113+
// navigationRef exists but .current is still null — hold off on attaching,
114+
// otherwise a subsequent timeout would leave a non-functional integration behind.
115+
expect(addIntegration).not.toHaveBeenCalled();
116+
expect(container.addListener).not.toHaveBeenCalled();
117+
118+
store.navigationRef.current = container;
119+
jest.advanceTimersByTime(50);
120+
121+
expect(addIntegration).toHaveBeenCalledTimes(1);
122+
expect(container.addListener).toHaveBeenCalledWith('__unsafe_action__', expect.any(Function));
123+
expect(container.addListener).toHaveBeenCalledWith('state', expect.any(Function));
124+
});
87125
});
88126

89127
describe('expo-router installed, navigationRef pre-populated', () => {
@@ -130,18 +168,20 @@ describe('expoRouterIntegration', () => {
130168
const integ = integration();
131169
integ.afterAllSetup?.(client);
132170

133-
// nothing registered yet
171+
// nothing attached yet — .current is still null
134172
expect(container.addListener).not.toHaveBeenCalled();
135-
expect(addIntegration).toHaveBeenCalledTimes(1);
173+
expect(addIntegration).not.toHaveBeenCalled();
136174

137175
// tick the polling timer once before ref is populated — still no registration
138176
jest.advanceTimersByTime(50);
139177
expect(container.addListener).not.toHaveBeenCalled();
178+
expect(addIntegration).not.toHaveBeenCalled();
140179

141180
// populate the ref and tick again
142181
navigationRef.current = container;
143182
jest.advanceTimersByTime(50);
144183

184+
expect(addIntegration).toHaveBeenCalledTimes(1);
145185
expect(container.addListener).toHaveBeenCalledWith('__unsafe_action__', expect.any(Function));
146186
expect(container.addListener).toHaveBeenCalledWith('state', expect.any(Function));
147187
});
@@ -157,7 +197,7 @@ describe('expoRouterIntegration', () => {
157197
);
158198

159199
const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration');
160-
const { client, closeHandlers } = createMockClient();
200+
const { client, addIntegration, closeHandlers } = createMockClient();
161201

162202
const integ = integration();
163203
integ.afterAllSetup?.(client);
@@ -167,6 +207,9 @@ describe('expoRouterIntegration', () => {
167207

168208
expect(jest.getTimerCount()).toBeLessThan(timersAfterSetup);
169209
expect(closeHandlers.length).toBe(1);
210+
// navigationRef existed the whole time but .current never populated — we must
211+
// not leave a `reactNavigationIntegration` attached that was never registered.
212+
expect(addIntegration).not.toHaveBeenCalled();
170213
});
171214
});
172215

0 commit comments

Comments
 (0)