From 2dd9cc9cc097151d87d84c17740d25a8e1c7480b Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 10 Jul 2026 14:27:14 +0200 Subject: [PATCH 1/5] fix(tracing): Skip duplicate navigation span from withAnchor POP_TO (#6434) Expo Router's `withAnchor` navigations can emit a second `POP_TO` dispatch to the destination route purely to stamp `initial: false` onto it. Since `startIdleNavigationSpan` runs on every `__unsafe_action__` dispatch and `POP_TO` was not filtered, this bookkeeping dispatch started a second idle navigation span that lingered until timeout and was sent as a spurious duplicate transaction (sometimes capturing in-flight HTTP/TTID children). Skip starting a navigation span for a `POP_TO` whose target is the route we are already on. The current route name is read from React Navigation's own `getCurrentRoute()` (raw name, same namespace as the action payload) rather than `latestRoute`, whose name may be rewritten by a route override provider such as Expo Router. Genuine `popTo`/`dismissTo` navigations to a different route are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 ++ .../core/src/js/tracing/reactnavigation.ts | 18 ++++++ .../core/test/tracing/reactnavigation.test.ts | 59 +++++++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6444058d29..50a3de1446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ - Add `Sentry.reportFullyDisplayed()` imperative API for signaling Time to Full Display ([#6419](https://github.com/getsentry/sentry-react-native/pull/6419)) +### Fixes + +- Fix duplicate navigation transaction on Expo Router `withAnchor` navigations ([#6439](https://github.com/getsentry/sentry-react-native/pull/6439)) + ## 8.18.0 ### Features diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index d56f9c7262..a89af845f6 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -537,6 +537,24 @@ export const reactNavigationIntegration = ({ return; } + // A `POP_TO` whose target is the route we are already on is redundant. Expo + // Router issues one right after a `withAnchor` navigation purely to stamp + // `initial: false` onto the destination; it carries no navigation of its + // own. Creating an idle span for it produces a spurious duplicate + // transaction (#6434). We compare against React Navigation's raw current + // route name (not `latestRoute`, whose name may be rewritten by a route + // override provider such as Expo Router), which shares the payload's + // namespace. + if (useDispatchedActionData && navigationActionType === 'POP_TO' && dispatchedRouteName) { + const currentRouteName = navigationContainer?.getCurrentRoute()?.name; + if (currentRouteName && currentRouteName === dispatchedRouteName) { + debug.log( + `${INTEGRATION_NAME} POP_TO targets the current route ${dispatchedRouteName}, not starting navigation span.`, + ); + return; + } + } + if (latestNavigationSpan) { debug.log(`${INTEGRATION_NAME} A transaction was detected that turned out to be a noop, discarding.`); _discardLatestTransaction(); diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index 86bb040b7c..74d7ca5c51 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -11,6 +11,7 @@ import { } from '@sentry/core'; import type { NavigationRoute } from '../../src/js/tracing/reactnavigation'; +import type { UnsafeAction } from '../../src/js/vendor/react-navigation/types'; import { nativeFramesIntegration, reactNativeTracingIntegration } from '../../src/js'; import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION } from '../../src/js/tracing/origin'; @@ -1277,6 +1278,64 @@ describe('ReactNavigationInstrumentation', () => { }); }); + describe('withAnchor POP_TO bookkeeping dispatch (#6434)', () => { + // `router.dismissTo('/ScreenB', { withAnchor: true })` navigates to ScreenB + // (a real POP_TO) and, on some expo-router versions, immediately issues a + // second POP_TO to the same route purely to stamp `initial: false`. That + // bookkeeping dispatch carries no state change of its own and must not + // produce a second, spurious navigation transaction. + const realPopTo: UnsafeAction = { + data: { + action: { type: 'POP_TO', payload: { name: 'ScreenB', params: {} } }, + noop: false, + stack: undefined, + }, + }; + const bookkeepingPopTo: UnsafeAction = { + data: { + action: { type: 'POP_TO', payload: { name: 'ScreenB', params: { initial: false } } }, + noop: false, + stack: undefined, + }, + }; + + function countTransactionsNamed(name: string): number { + return client.eventQueue.filter(e => e.type === 'transaction' && e.transaction === name).length; + } + + test('does not create a second transaction for the bookkeeping dispatch', async () => { + setupTestClient({ useDispatchedActionData: true }); + await jest.runOnlyPendingTimers(); // Flush the initial navigation span + client.eventQueue = []; + + // Real navigation to ScreenB — resolves via a state change. + mockNavigation.emitWithStateChange(realPopTo, { key: 'screen_b', name: 'ScreenB' }); + await jest.runOnlyPendingTimersAsync(); + + // withAnchor bookkeeping POP_TO to the same route. It only flips + // `params.initial`, which React Navigation reports as a state change to + // the same route key. + mockNavigation.emitWithStateChange(bookkeepingPopTo, { key: 'screen_b', name: 'ScreenB' }); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expect(countTransactionsNamed('ScreenB')).toBe(1); + }); + + test('still creates a transaction for a genuine popTo navigation', async () => { + setupTestClient({ useDispatchedActionData: true }); + await jest.runOnlyPendingTimers(); // Flush the initial navigation span + client.eventQueue = []; + + // A real dismissTo/popTo to a different route must still be traced. + mockNavigation.emitWithStateChange(realPopTo, { key: 'screen_b', name: 'ScreenB' }); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expect(countTransactionsNamed('ScreenB')).toBe(1); + }); + }); + test('noop does not remove the previous navigation span from scope', async () => { setupTestClient({ useDispatchedActionData: true }); await jest.runOnlyPendingTimers(); // Flushes the initial navigation span From a457a9a0e2a22b7f106c270ea1731978cd50878f Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 24 Jul 2026 12:36:38 +0200 Subject: [PATCH 2/5] fix(tracing): Adopt two-guard withAnchor POP_TO handling (#6434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the dispatch-time-only, leaf-name dedup with the more complete two-guard design from #6472, plus a refinement that closes a false-suppression regression both prior approaches shared. - Dispatch-time guard (`isRouteFocused`): skip a `POP_TO` whose target is already focused on the active route chain AND that carries the `withAnchor` marker (`params.initial === false`). Requiring the marker is what lets a genuine `popTo` to an earlier same-named route in the stack (e.g. `[id]` → `[id]`, different `route.key`) through, since those never carry `initial`. - State-change guard: discard a `POP_TO` span that landed on the same `route.key` (a bookkeeping dispatch that slipped past the dispatch-time filter, e.g. a nested payload name), unless a deep link claimed it. Keyed on `route.key`, so it never affects a navigation that changed the route. `applyPendingDeepLinkToSpan` now returns whether it attached, so the state-change guard can preserve deep-link-to-current-screen attribution. Co-authored-by: Cryptoteep Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 8 +- .../core/src/js/tracing/reactnavigation.ts | 104 ++++++++++++++---- .../core/test/tracing/reactnavigation.test.ts | 65 +++++++++++ 3 files changed, 149 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69b0a253a0..f44eb82131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## Unreleased +### Fixes + +- Fix duplicate navigation transaction on Expo Router `withAnchor` navigations ([#6439](https://github.com/getsentry/sentry-react-native/pull/6439)) + ### Dependencies - Bump Android SDK from v8.49.0 to v8.50.1 ([#6503](https://github.com/getsentry/sentry-react-native/pull/6503)) @@ -89,10 +93,6 @@ - [changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md#8490) - [diff](https://github.com/getsentry/sentry-java/compare/8.48.0...8.49.0) -### Fixes - -- Fix duplicate navigation transaction on Expo Router `withAnchor` navigations ([#6439](https://github.com/getsentry/sentry-react-native/pull/6439)) - ## 8.18.0 ### Features diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index b1a8d778a9..b1268837fa 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -261,7 +261,8 @@ export const reactNavigationIntegration = ({ const spanDispatchSeq = new WeakMap(); /** - * Attempts to attach the pending deep link to the given span. + * Attempts to attach the pending deep link to the given span. Returns `true` + * when the link was attached. * * Warm-open links only attach to spans dispatched *after* the link was * received. This prevents an unrelated, already-in-flight navigation from @@ -274,10 +275,10 @@ export const reactNavigationIntegration = ({ * Rejected warm-open links are left in the slot to be picked up by the next * eligible span. */ - const applyPendingDeepLinkToSpan = (span: Span, maxAgeMs: number): void => { + const applyPendingDeepLinkToSpan = (span: Span, maxAgeMs: number): boolean => { const pending = peekPendingDeepLink(maxAgeMs); if (!pending) { - return; + return false; } if (pending.source === 'warm-open') { const spanSeq = spanDispatchSeq.get(span); @@ -285,11 +286,11 @@ export const reactNavigationIntegration = ({ // Span was dispatched before (or at the same tick as) the link arrived // — it cannot be the navigation the link triggered. Leave the link in // the slot for the next eligible span. - return; + return false; } } consumePendingDeepLink(maxAgeMs); - tagSpanWithDeepLink(span, pending); + return tagSpanWithDeepLink(span, pending); }; /** @@ -442,6 +443,32 @@ export const reactNavigationIntegration = ({ initialStateHandled = true; }; + /** + * Returns `true` when the given route name is focused at some level of the + * current navigation state — i.e. it is on the chain of active routes from + * the root navigator down to the leaf. Falls back to comparing against the + * leaf route only when the full state is not available. + */ + const isRouteFocused = (routeName: string): boolean => { + try { + const rootState = navigationContainer?.getState(); + let currentState: NavigationState | undefined = rootState; + while (currentState) { + const route: NavigationRoute | undefined = currentState.routes[currentState.index ?? 0]; + if (route?.name === routeName) { + return true; + } + currentState = route?.state; + } + if (!rootState) { + return navigationContainer?.getCurrentRoute()?.name === routeName; + } + } catch (e) { + debug.warn(`${INTEGRATION_NAME} Failed to read navigation state to check focused route.`, e); + } + return false; + }; + /** * To be called on every React-Navigation action dispatch. * It does not name the transaction or populate it with route information. Instead, it waits for the state to fully change @@ -537,6 +564,34 @@ export const reactNavigationIntegration = ({ return; } + // A `POP_TO` that both targets an already-focused route AND carries the + // `withAnchor` marker (`params.initial === false`) is not a user-facing + // navigation — it's Expo Router's bookkeeping dispatch, emitted right after + // a `withAnchor` navigation purely to stamp `initial: false` onto the + // destination it just navigated to. Starting a span here would either + // discard the real navigation's in-flight span (state changes are applied + // in a deferred microtask, see #6436) or ship a spurious duplicate + // transaction that steals the real navigation's child spans (#6434). + // + // Requiring the `initial === false` marker (only ever set by `withAnchor`, + // see expo-router's `getNavigationAction`) is what keeps a genuine `popTo` + // to an earlier *same-named* route in the stack (e.g. `[id]` → `[id]`) from + // being wrongly skipped: those carry no `initial` param. The `route.key` + // check in `updateLatestNavigationSpanWithCurrentRoute` backstops any + // bookkeeping dispatch that slips past this filter. + const popToParams = (event?.data?.action?.payload as { params?: { initial?: boolean } } | undefined)?.params; + if ( + navigationActionType === 'POP_TO' && + targetRouteName && + popToParams?.initial === false && + isRouteFocused(targetRouteName) + ) { + debug.log( + `${INTEGRATION_NAME} POP_TO targets the already focused route ${targetRouteName}, not starting navigation span.`, + ); + return; + } + // Extract route name from dispatch action payload when available const dispatchedRouteName = useDispatchedActionData ? targetRouteName : undefined; if (useDispatchedActionData && event && !dispatchedRouteName && !isAppRestart) { @@ -544,24 +599,6 @@ export const reactNavigationIntegration = ({ return; } - // A `POP_TO` whose target is the route we are already on is redundant. Expo - // Router issues one right after a `withAnchor` navigation purely to stamp - // `initial: false` onto the destination; it carries no navigation of its - // own. Creating an idle span for it produces a spurious duplicate - // transaction (#6434). We compare against React Navigation's raw current - // route name (not `latestRoute`, whose name may be rewritten by a route - // override provider such as Expo Router), which shares the payload's - // namespace. - if (useDispatchedActionData && navigationActionType === 'POP_TO' && dispatchedRouteName) { - const currentRouteName = navigationContainer?.getCurrentRoute()?.name; - if (currentRouteName && currentRouteName === dispatchedRouteName) { - debug.log( - `${INTEGRATION_NAME} POP_TO targets the current route ${dispatchedRouteName}, not starting navigation span.`, - ); - return; - } - } - if (latestNavigationSpan) { debug.log(`${INTEGRATION_NAME} A transaction was detected that turned out to be a noop, discarding.`); _discardLatestTransaction(); @@ -679,10 +716,29 @@ export const reactNavigationIntegration = ({ // deep link (e.g. deep-linking to the screen you're already on). Make // sure the pending link still gets attributed before we drop the span // reference. - applyPendingDeepLinkToSpan(latestNavigationSpan, routeChangeTimeoutMs); + const deepLinkAttached = applyPendingDeepLinkToSpan(latestNavigationSpan, routeChangeTimeoutMs); pushRecentRouteKey(route.key); latestRoute = route; + // A `POP_TO` span that landed on the route we were already on (same + // `route.key`) was a params-only bookkeeping dispatch — e.g. Expo + // Router's `withAnchor` anchor stamping — that slipped past the + // dispatch-time filter (for instance a nested destination whose payload + // name matches none of the focused route names). Letting the span run + // would ship a spurious duplicate transaction that collects the real + // navigation's in-flight child spans, so discard it unless a deep link + // claimed it above (#6434). Keyed on `route.key`, this never affects a + // genuine navigation that actually changed the route. + if ( + !deepLinkAttached && + spanToJSON(latestNavigationSpan).data?.[SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE] === 'POP_TO' + ) { + debug.log(`[${INTEGRATION_NAME}] Discarding POP_TO navigation span that did not change the route.`); + clearStateChangeTimeout(); + _discardLatestTransaction(); + return undefined; + } + // Clear the latest transaction as it has been handled. latestNavigationSpan = undefined; return undefined; diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index b4b459f288..db73bd8bb1 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -1334,6 +1334,71 @@ describe('ReactNavigationInstrumentation', () => { expect(countTransactionsNamed('ScreenB')).toBe(1); }); + + test('still creates a span for a popTo to an earlier same-named route in the stack', async () => { + // Stack with a repeated route name (e.g. Expo Router `[id]`): on Detail(#2), + // popTo the earlier Detail(#1). The target name matches the focused leaf + // name, but the bookkeeping marker (`initial: false`) is absent, so this + // real, key-changing navigation must NOT be filtered. + setupTestClient({ useDispatchedActionData: true }); + await jest.runOnlyPendingTimers(); + client.eventQueue = []; + + mockNavigation.emitWithStateChange( + { data: { action: { type: 'PUSH', payload: { name: 'Detail' } }, noop: false, stack: undefined } }, + { key: 'detail_2', name: 'Detail' }, + ); + await jest.runOnlyPendingTimersAsync(); + client.eventQueue = []; + + mockNavigation.emitWithStateChange( + { data: { action: { type: 'POP_TO', payload: { name: 'Detail' } }, noop: false, stack: undefined } }, + { key: 'detail_1', name: 'Detail' }, + ); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expect(countTransactionsNamed('Detail')).toBe(1); + }); + + test('discards a bookkeeping POP_TO that slips past the dispatch-time filter (same route key)', async () => { + // Payload name matches no focused route (e.g. a parent navigator name), + // so the dispatch-time filter misses, but the state change lands on the + // same route key — the state-change guard must discard the span. + setupTestClient({ useDispatchedActionData: true }); + await jest.runOnlyPendingTimers(); + client.eventQueue = []; + + mockNavigation.emitWithStateChange({ + data: { + action: { type: 'POP_TO', payload: { name: '(app)', params: { initial: false } } }, + noop: false, + stack: undefined, + }, + }); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expect(client.eventQueue.filter(e => e.type === 'transaction').length).toBe(0); + }); + + test('a focused-route bookkeeping POP_TO does not tear down an in-flight navigation span', async () => { + setupTestClient({ useDispatchedActionData: true }); + await jest.runOnlyPendingTimers(); + + mockNavigation.emitNavigationWithoutStateChange(); + const activeSpan = getActiveSpan(); + + mockNavigation.emitWithoutStateChange({ + data: { + action: { type: 'POP_TO', payload: { name: 'Initial Screen', params: { initial: false } } }, + noop: false, + stack: undefined, + }, + }); + + expect(getActiveSpan()).toBe(activeSpan); + }); }); test('noop does not remove the previous navigation span from scope', async () => { From afeba2a4746ded38d0d1b5f07a37aede0a70e53f Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 24 Jul 2026 12:58:51 +0200 Subject: [PATCH 3/5] fix(tracing): Address review on POP_TO bookkeeping guard (#6434) - Preserve deep-link attribution on a same-route POP_TO: check the `taggedDeepLinkSpans` set instead of the just-attached result, so a span tagged earlier via the synchronous late-arrival listener is not discarded (revert `applyPendingDeepLinkToSpan` back to `void`). Reported by cursor bot. - End `navigationProcessingSpan` before dropping it in `_discardLatestTransaction` so the bookkeeping-discard path does not leave an unfinished span dangling. Reported by sentry bot. - Add a test that a same-route POP_TO carrying a deep link is kept. Co-authored-by: Cryptoteep Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/src/js/tracing/reactnavigation.ts | 27 +++++++++++-------- .../core/test/tracing/reactnavigation.test.ts | 22 +++++++++++++++ 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index b1268837fa..975e4ef256 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -261,8 +261,7 @@ export const reactNavigationIntegration = ({ const spanDispatchSeq = new WeakMap(); /** - * Attempts to attach the pending deep link to the given span. Returns `true` - * when the link was attached. + * Attempts to attach the pending deep link to the given span. * * Warm-open links only attach to spans dispatched *after* the link was * received. This prevents an unrelated, already-in-flight navigation from @@ -275,10 +274,10 @@ export const reactNavigationIntegration = ({ * Rejected warm-open links are left in the slot to be picked up by the next * eligible span. */ - const applyPendingDeepLinkToSpan = (span: Span, maxAgeMs: number): boolean => { + const applyPendingDeepLinkToSpan = (span: Span, maxAgeMs: number): void => { const pending = peekPendingDeepLink(maxAgeMs); if (!pending) { - return false; + return; } if (pending.source === 'warm-open') { const spanSeq = spanDispatchSeq.get(span); @@ -286,11 +285,11 @@ export const reactNavigationIntegration = ({ // Span was dispatched before (or at the same tick as) the link arrived // — it cannot be the navigation the link triggered. Leave the link in // the slot for the next eligible span. - return false; + return; } } consumePendingDeepLink(maxAgeMs); - return tagSpanWithDeepLink(span, pending); + tagSpanWithDeepLink(span, pending); }; /** @@ -716,7 +715,7 @@ export const reactNavigationIntegration = ({ // deep link (e.g. deep-linking to the screen you're already on). Make // sure the pending link still gets attributed before we drop the span // reference. - const deepLinkAttached = applyPendingDeepLinkToSpan(latestNavigationSpan, routeChangeTimeoutMs); + applyPendingDeepLinkToSpan(latestNavigationSpan, routeChangeTimeoutMs); pushRecentRouteKey(route.key); latestRoute = route; @@ -726,11 +725,13 @@ export const reactNavigationIntegration = ({ // dispatch-time filter (for instance a nested destination whose payload // name matches none of the focused route names). Letting the span run // would ship a spurious duplicate transaction that collects the real - // navigation's in-flight child spans, so discard it unless a deep link - // claimed it above (#6434). Keyed on `route.key`, this never affects a - // genuine navigation that actually changed the route. + // navigation's in-flight child spans, so discard it unless it carries a + // deep link (#6434). We check `taggedDeepLinkSpans` rather than the + // just-attached result so a span tagged earlier via the synchronous + // late-arrival listener is also preserved. Keyed on `route.key`, this + // never affects a genuine navigation that actually changed the route. if ( - !deepLinkAttached && + !taggedDeepLinkSpans.has(latestNavigationSpan) && spanToJSON(latestNavigationSpan).data?.[SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE] === 'POP_TO' ) { debug.log(`[${INTEGRATION_NAME}] Discarding POP_TO navigation span that did not change the route.`); @@ -851,6 +852,10 @@ export const reactNavigationIntegration = ({ latestNavigationSpan = undefined; } if (navigationProcessingSpan) { + // End before dropping the reference so we don't leave an unfinished span + // dangling. It is a child of the (now discarded) navigation span, so it + // is dropped with the transaction rather than sent on its own. + navigationProcessingSpan.end(); navigationProcessingSpan = undefined; } }; diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index db73bd8bb1..8ab08d5f86 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -1382,6 +1382,28 @@ describe('ReactNavigationInstrumentation', () => { expect(client.eventQueue.filter(e => e.type === 'transaction').length).toBe(0); }); + test('keeps a same-route POP_TO span that carries a deep link', async () => { + // A same-route POP_TO that is actually a deep link to the screen you are + // already on must NOT be discarded by the bookkeeping guard. + setupTestClient({ useDispatchedActionData: true }); + await jest.runOnlyPendingTimers(); + client.eventQueue = []; + + setPendingDeepLink('myapp://initial-screen', 'warm-open'); + mockNavigation.emitWithStateChange({ + data: { + action: { type: 'POP_TO', payload: { name: '(app)', params: { initial: false } } }, + noop: false, + stack: undefined, + }, + }); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expect(client.event?.contexts?.trace?.data?.['navigation.trigger']).toBe('deeplink'); + clearPendingDeepLink(); + }); + test('a focused-route bookkeeping POP_TO does not tear down an in-flight navigation span', async () => { setupTestClient({ useDispatchedActionData: true }); await jest.runOnlyPendingTimers(); From a7204aa4070621fa5dec7ebc7ae95b9f01aa70aa Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 24 Jul 2026 13:48:40 +0200 Subject: [PATCH 4/5] test(tracing): Make in-flight-span POP_TO test actually create a span (#6434) The test used `emitNavigationWithoutStateChange()`, whose payload-less NAVIGATE is rejected by the `!dispatchedRouteName` guard under `useDispatchedActionData: true`, so no in-flight span was ever created and the assertion was trivially true. Start a real in-flight navigation via a payloaded NAVIGATE dispatch (no state change) so the test genuinely exercises the focused-route POP_TO not tearing it down. Verified it fails when the dispatch-time guard is removed. Reported by warden bot. Co-authored-by: Cryptoteep Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/test/tracing/reactnavigation.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index 8ab08d5f86..97d137fc26 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -1408,9 +1408,20 @@ describe('ReactNavigationInstrumentation', () => { setupTestClient({ useDispatchedActionData: true }); await jest.runOnlyPendingTimers(); - mockNavigation.emitNavigationWithoutStateChange(); + // Start a real, still-in-flight navigation (dispatch with a route name in + // the payload, but no state change yet). + mockNavigation.emitWithoutStateChange({ + data: { + action: { type: 'NAVIGATE', payload: { name: 'New Screen' } }, + noop: false, + stack: undefined, + }, + }); const activeSpan = getActiveSpan(); + expect(activeSpan).toBeDefined(); + // The withAnchor bookkeeping POP_TO to the already-focused route must not + // discard that in-flight span via the "noop transaction" branch. mockNavigation.emitWithoutStateChange({ data: { action: { type: 'POP_TO', payload: { name: 'Initial Screen', params: { initial: false } } }, From 0a06b99fb5a3fa234f2ed0eb227b29e2c5fd18b3 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 24 Jul 2026 15:06:27 +0200 Subject: [PATCH 5/5] fix(tracing): Apply withAnchor POP_TO guards regardless of useDispatchedActionData (#6434) The two guards were keyed off `navigationActionType` / the span's action-type attribute, both only populated when `useDispatchedActionData` is enabled. That option defaults off for `expoRouterIntegration` and the Expo sample, so the duplicate POP_TO transaction still occurred on the default setup where the bug was reported (confirmed: 2 transactions with the flag off). Drive the dispatch-time guard off `actionType` (parsed unconditionally) and track the latest navigation span's action type in a closure variable for the state-change discard, so both guards work regardless of the flag. The span attribute stays flag-gated to avoid changing trace metadata for existing users. The guards remain narrow: guard 1 requires `params.initial === false` (only set by withAnchor), guard 2 only discards a same-route-key POP_TO (a no-op pop). Add a test asserting the default (flag-off) setup no longer creates the duplicate transaction. Co-authored-by: Cryptoteep Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/js/tracing/reactnavigation.ts | 15 ++++++++++----- .../core/test/tracing/reactnavigation.test.ts | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index 975e4ef256..550aad6236 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -237,6 +237,10 @@ export const reactNavigationIntegration = ({ let latestNavigationSpan: Span | undefined; let latestNavigationSpanNameCustomized: boolean = false; + // Action type of the dispatch that started `latestNavigationSpan`. Tracked + // independently of the (flag-gated) span attribute so the same-route discard + // works even when `useDispatchedActionData` is off. + let latestNavigationActionType: string | undefined; let navigationProcessingSpan: Span | undefined; /** * The first nav span that successfully completed a state change — i.e. the @@ -578,9 +582,12 @@ export const reactNavigationIntegration = ({ // being wrongly skipped: those carry no `initial` param. The `route.key` // check in `updateLatestNavigationSpanWithCurrentRoute` backstops any // bookkeeping dispatch that slips past this filter. + // Driven off `actionType` (parsed unconditionally), not `navigationActionType`, + // so it also applies to the default `expoRouterIntegration` setup where + // `useDispatchedActionData` is off — that is where this bug was reported. const popToParams = (event?.data?.action?.payload as { params?: { initial?: boolean } } | undefined)?.params; if ( - navigationActionType === 'POP_TO' && + actionType === 'POP_TO' && targetRouteName && popToParams?.initial === false && isRouteFocused(targetRouteName) @@ -616,6 +623,7 @@ export const reactNavigationIntegration = ({ latestNavigationSpanNameCustomized = finalSpanOptions.name !== originalName; latestNavigationSpan = startGenericIdleNavigationSpan(finalSpanOptions, { ...idleSpanOptions, isAppRestart }); + latestNavigationActionType = actionType; latestNavigationSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION); latestNavigationSpan?.setAttribute(SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE, navigationActionType); @@ -730,10 +738,7 @@ export const reactNavigationIntegration = ({ // just-attached result so a span tagged earlier via the synchronous // late-arrival listener is also preserved. Keyed on `route.key`, this // never affects a genuine navigation that actually changed the route. - if ( - !taggedDeepLinkSpans.has(latestNavigationSpan) && - spanToJSON(latestNavigationSpan).data?.[SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE] === 'POP_TO' - ) { + if (!taggedDeepLinkSpans.has(latestNavigationSpan) && latestNavigationActionType === 'POP_TO') { debug.log(`[${INTEGRATION_NAME}] Discarding POP_TO navigation span that did not change the route.`); clearStateChangeTimeout(); _discardLatestTransaction(); diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index 97d137fc26..5e1c2e93d0 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -1322,6 +1322,23 @@ describe('ReactNavigationInstrumentation', () => { expect(countTransactionsNamed('ScreenB')).toBe(1); }); + test('does not create a second transaction with useDispatchedActionData disabled (default Expo setup)', async () => { + // The guards are driven off `actionType`, not the opt-in flag, so the + // default `expoRouterIntegration` setup (flag off) is fixed too. + setupTestClient({ useDispatchedActionData: false }); + await jest.runOnlyPendingTimers(); + client.eventQueue = []; + + mockNavigation.emitWithStateChange(realPopTo, { key: 'screen_b', name: 'ScreenB' }); + await jest.runOnlyPendingTimersAsync(); + + mockNavigation.emitWithStateChange(bookkeepingPopTo, { key: 'screen_b', name: 'ScreenB' }); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expect(client.eventQueue.filter(e => e.type === 'transaction').length).toBe(1); + }); + test('still creates a transaction for a genuine popTo navigation', async () => { setupTestClient({ useDispatchedActionData: true }); await jest.runOnlyPendingTimers(); // Flush the initial navigation span