From a8d5202af8ba92e35a9896f52e5b7d4716e2e0ac Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 16 Jul 2026 11:22:09 +0200 Subject: [PATCH 1/2] fix(core): Defer route override read until after state-change chain React Navigation fires `emit('state')` synchronously before invoking the `onStateChange` prop. Integrations like Expo Router refresh their route cache (which the override provider reads) inside that prop, so reading the override synchronously in the `state` listener returns the previous route for the current transition. Defer the read with a microtask so the override read happens after the synchronous state-change chain completes. Fixes #6436 --- CHANGELOG.md | 1 + .../core/src/js/tracing/reactnavigation.ts | 30 +++++++++++++- .../core/test/tracing/reactnavigation.test.ts | 39 +++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 184951bdb9..fa65e9f575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Fix `TypeError` when `showFeedbackForm`/`showFeedbackButton`/`showScreenshotButton` is called before `FeedbackFormProvider` mounts ([#6435](https://github.com/getsentry/sentry-react-native/pull/6435)) - Fix orphaned TTID/TTFD spans in the trace view ([#6437](https://github.com/getsentry/sentry-react-native/pull/6437)) - 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)) +- Fix `reactNavigationIntegration` reading a stale route from an override provider (e.g. `expoRouterIntegration`), causing navigation transactions to be named/attributed for the previous route ([#6436](https://github.com/getsentry/sentry-react-native/issues/6436)) ### Internal diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index d56f9c7262..98f858fcc9 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -411,7 +411,14 @@ export const reactNavigationIntegration = ({ // This action is emitted on every dispatch navigationContainer.addListener('__unsafe_action__', startIdleNavigationSpan); - navigationContainer.addListener('state', updateLatestNavigationSpanWithCurrentRoute); + // React Navigation fires `emit('state')` synchronously BEFORE the + // `onStateChange` prop callback. Integrations like Expo Router refresh + // their route cache (which our route override provider reads) inside that + // `onStateChange`, so reading the override synchronously here would return + // the previous route for the current transition. Defer with a microtask + // so the read happens after the synchronous state-change chain completes + // and downstream caches have caught up. See #6436. + navigationContainer.addListener('state', scheduleUpdateLatestNavigationSpanWithCurrentRoute); RN_GLOBAL_OBJ.__sentry_rn_v5_registered = true; if (initialStateHandled) { @@ -597,6 +604,27 @@ export const reactNavigationIntegration = ({ stateChangeTimeout = setTimeout(_discardLatestTransaction, routeChangeTimeoutMs); }; + /** + * Defer {@link updateLatestNavigationSpanWithCurrentRoute} until the current + * synchronous state-change chain unwinds so route override providers backed + * by a downstream cache (e.g. Expo Router's router-store, which is refreshed + * via `NavigationContainer.onStateChange`) have picked up the new route. See + * #6436. + */ + const scheduleUpdateLatestNavigationSpanWithCurrentRoute = (): void => { + const g = globalThis as unknown as { queueMicrotask?: (cb: () => void) => void }; + if (typeof g.queueMicrotask === 'function') { + g.queueMicrotask(updateLatestNavigationSpanWithCurrentRoute); + return; + } + // Fallback for runtimes without `queueMicrotask`. `.catch()` handler is + // there only to satisfy the `no-floating-promises` lint — the update + // function does not throw. + Promise.resolve() + .then(updateLatestNavigationSpanWithCurrentRoute) + .catch(() => {}); + }; + /** * To be called AFTER the state has been changed to populate the transaction with the current route. */ diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index 86bb040b7c..d9079a980e 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -2007,6 +2007,45 @@ describe('ReactNavigationInstrumentation', () => { expect(traceData[SEMANTIC_ATTRIBUTE_ROUTE_NAME]).toBe('/posts/[...slug]'); expect(traceData[SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_NAME]).toBe('/profile/[id]'); }); + + // Regression test for #6436. React Navigation's BaseNavigationContainer + // fires `emit('state')` synchronously BEFORE the `onStateChange` prop + // callback, and Expo Router refreshes its router-store cache from that + // prop. Reading the override synchronously in the `state` listener would + // return the previous route for the current transition. The integration + // must defer the read until after the synchronous state-change chain has + // unwound. + it('reads the route override after the synchronous state-change chain (Expo Router ordering)', async () => { + const rNavigation = setupWithOverride(); + jest.runOnlyPendingTimers(); + + // First navigation: settle on '/A' so the provider has a "previous" value. + let latestOverride: { templatedPath: string; params?: Record } = { + templatedPath: '/A', + }; + rNavigation._setRouteOverrideProvider(() => latestOverride); + + mockNavigation.navigateToDynamicRoute(); + jest.runOnlyPendingTimers(); + await client.flush(); + + // Second navigation: simulate the Expo Router ordering. When the `state` + // event fires the override provider still returns '/A' (Expo Router has + // not yet run its own `onStateChange` prop callback). Flip the provider + // to '/B' AFTER `navigateToCatchAllRoute()` returns but BEFORE the + // pending microtask flushes — this mimics `onStateChange` refreshing + // the store cache right after `emit('state')` returns. + mockNavigation.navigateToCatchAllRoute(); + latestOverride = { templatedPath: '/B' }; + jest.runOnlyPendingTimers(); + await client.flush(); + + const traceData = client.event?.contexts?.trace?.data as Record; + expect(client.event?.transaction).toBe('/B'); + expect(traceData[SEMANTIC_ATTRIBUTE_ROUTE_NAME]).toBe('/B'); + expect(traceData['route.path']).toBe('/B'); + expect(traceData[SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_NAME]).toBe('/A'); + }); }); describe('dispatch breadcrumbs', () => { From e381d8003959cc3246d486dd279e840e0a9c9d9d Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 16 Jul 2026 11:27:36 +0200 Subject: [PATCH 2/2] docs(changelog): Point #6436 entry at the PR --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa65e9f575..cd48ebda05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ - Fix `TypeError` when `showFeedbackForm`/`showFeedbackButton`/`showScreenshotButton` is called before `FeedbackFormProvider` mounts ([#6435](https://github.com/getsentry/sentry-react-native/pull/6435)) - Fix orphaned TTID/TTFD spans in the trace view ([#6437](https://github.com/getsentry/sentry-react-native/pull/6437)) - 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)) -- Fix `reactNavigationIntegration` reading a stale route from an override provider (e.g. `expoRouterIntegration`), causing navigation transactions to be named/attributed for the previous route ([#6436](https://github.com/getsentry/sentry-react-native/issues/6436)) +- 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)) ### Internal