Repository navigation
reactNavigationIntegration: a NAVIGATE onto the already-focused route abandons its span, shipping an empty "Route Change" transaction #6593
Description
What React Native libraries do you use?
React Native without Frameworks, React Navigation, Hermes, RN New Architecture
Are you using sentry.io or on-premise?
on-premise (Self-Hosted)
Are you using any other error monitoring solution alongside Sentry?
No
@sentry/react-native SDK Version
7.13.0
We verified the same code path is unchanged on main (cda137c68027b9b6c46a2bac28e9d81567b4d5bc), so this is not specific to the 7.x line. Where the two differ, we call it out below.
How does your development environment look like?
React Native 0.86.0, React 19.2.3, Hermes, New Architecture enabled (newArchEnabled=true).
@react-navigation/native 7.3.1, @react-navigation/stack 7.10.3, @react-navigation/bottom-tabs 7.18.0,
react-native-screens 4.25.2.
Observed in production on both Android and iOS. Self-hosted Sentry.
Sentry.init()
const navigationIntegration = Sentry.reactNavigationIntegration({
enableTimeToInitialDisplay: true,
useDispatchedActionData: true,
});
Sentry.init({
dsn: 'https://...',
enableAutoSessionTracking: true,
enableAutoPerformanceTracing: true,
tracesSampleRate: 0.2,
integrations: [navigationIntegration],
// Our current mitigation, described below. We would like to delete it.
beforeSendTransaction: dropOrphanedNavigationTransaction,
});Steps to Reproduce
-
Register
reactNavigationIntegrationas above and navigate to a screen, e.g.navigation.navigate('Chat', { id: 1 }). -
While that screen is focused, dispatch a
NAVIGATEthat resolves onto the route already on top of the stack — for examplenavigation.navigate('Chat', { id: 2 }). React Navigation updates the params of the existing screen, sogetCurrentRoute().keyis unchanged.In our app this is a real user flow: tapping a push notification for conversation B while conversation A is already open.
-
Observe the transaction the SDK emits for that dispatch.
Expected Result
Either the navigation span is discarded (it carries no route information, which is what ignoreEmptyRouteChangeTransactions exists to prevent shipping), or it is treated as a genuine screen entry and named/attributed like any other navigation so that its duration and its display-timing children are attributable to the screen.
Whichever is chosen, the navigation.processing child should be ended on this path and the state-change timeout cleared, as they are on the normal path.
Actual Result
An unnamed transaction named Route Change is emitted with no route.name, route.key, route.has_been_seen or previous_route.* attributes. Because it has op: navigation but no route attribution, it lands in navigation data as an anonymous entry and drags navigation-latency aggregates with it, and the screen entry it represents contributes nothing to per-screen TTID/TTFD aggregates.
Mechanism
Line references are to packages/core/src/js/tracing/reactnavigation.ts at cda137c6 (behaviour identical in the 7.13.0 dist/js/tracing/reactnavigation.js, cited in parentheses).
-
The dispatch is not filtered.
startIdleNavigationSpan'suseDispatchedActionDataskip list coversPRELOAD,SET_PARAMSand the three drawer actions (L552-L565; 7.13.0 L178-L191). ANAVIGATEis not in it, so a navigation span is started as normal — correctly, since at dispatch time the SDK cannot yet know the state change will be a no-op. -
The state listener bails out before doing any of its bookkeeping. In
updateLatestNavigationSpanWithCurrentRoute,previousRoute?.key === route.keytakes an early return (L720-L751; 7.13.0 L239-L246) that:- does not end
navigationProcessingSpan, which the normal path ends at L793-L796 (7.13.0 L254-L257); - does not call
clearStateChangeTimeout(), so thesetTimeout(_discardLatestTransaction, routeChangeTimeoutMs)armed at L666 (7.13.0 L217) is still pending; - sets
latestNavigationSpan = undefinedat L749 (7.13.0 L244) and returns, leaving the root span unnamed and unattributed.
mainadded a discard here forPOP_TOspecifically (L741-L746, fromwithAnchor's internalPOP_TObookkeeping dispatch creates a spurious duplicate navigation span #6434). A plainNAVIGATEfalls through it. - does not end
-
Clearing the handle disarms the SDK's own discard.
ignoreEmptyRouteChangeTransactionsis installed at L651 (7.13.0 L206-L207) withisSpanStillTracked = () => latestNavigationSpan === spanToCheck. That predicate is exactly what step 2 falsifies, soshouldDiscardFninonSpanEndUtils.tsreturnsfalseand the empty transaction is sent. This looks unintentional rather than deliberate: the check was added in #5387 for thegetCurrentRoute() === undefinedcase (#3991), where the state listener returns without clearing the handle. The same-route return clears the handle as though the span had been handled, but it was never named. -
ignoreEmptyBackNavigationcannot cover it either, because it discards only whenroute.has_been_seen === true, and that attribute is set in the naming block the early return skipped. -
The stray timeout then fires with nothing left to discard.
_discardLatestTransactionruns atrouteChangeTimeoutMs, butlatestNavigationSpanis alreadyundefined, so the root span is untouched.This is where the two versions diverge. On
main,_discardLatestTransactionendsnavigationProcessingSpan(L859-L865), so the child closes at ~routeChangeTimeoutMsand the idle span closes shortly after — the transaction is short but still empty and unnamed. On 7.13.0 the same function only drops the reference without ending the span (L313-L315), so the child stays open and the idle span is held until@sentry/core'schildSpanTimeout(15000 ms,tracing/idleSpan.js) ends it withheartbeatFailed. That is the ~15 s duration we see in production; it is an artifact of the timeout, not of the navigation.Steps 1-4 are the same on both versions; only the resulting duration differs.
Our current mitigation
We drop these in beforeSendTransaction, matching on the signature the SDK's own check looks for:
const DEFAULT_NAVIGATION_SPAN_NAME = 'Route Change';
export function dropOrphanedNavigationTransaction(event: TransactionEvent): TransactionEvent | null {
if (event.transaction !== DEFAULT_NAVIGATION_SPAN_NAME) return event;
if (event.contexts?.trace?.data?.['route.name'] !== undefined) return event;
return null;
}This keeps the aggregates clean but is indiscriminate — it also drops the transactions the SDK intends to discard for other reasons, and it cannot recover the screen entry that went unmeasured. We would rather delete it than carry it.
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsNo status
Activity
linear-code commentedon Aug 17, 2026
RN-736
antonis commentedon Aug 18, 2026
Thank you for reporting 🙇 We will investigate this and iterate back.
test(tracing): Cover default-config repro and no-op same-route path (#…
fix(tracing): Name transaction when re-navigating to focused route wi…
github-actions commentedon Aug 27, 2026
A PR closing this issue has just been released 🚀
This issue was referenced by PR #6606, which was included in the 8.24.0 release.