Skip to content

reactNavigationIntegration: a NAVIGATE onto the already-focused route abandons its span, shipping an empty "Route Change" transaction #6593

Description

@alupher

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

  1. Register reactNavigationIntegration as above and navigate to a screen, e.g. navigation.navigate('Chat', { id: 1 }).

  2. While that screen is focused, dispatch a NAVIGATE that resolves onto the route already on top of the stack — for example navigation.navigate('Chat', { id: 2 }). React Navigation updates the params of the existing screen, so getCurrentRoute().key is unchanged.

    In our app this is a real user flow: tapping a push notification for conversation B while conversation A is already open.

  3. 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).

  1. The dispatch is not filtered. startIdleNavigationSpan's useDispatchedActionData skip list covers PRELOAD, SET_PARAMS and the three drawer actions (L552-L565; 7.13.0 L178-L191). A NAVIGATE is 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.

  2. The state listener bails out before doing any of its bookkeeping. In updateLatestNavigationSpanWithCurrentRoute, previousRoute?.key === route.key takes 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 the setTimeout(_discardLatestTransaction, routeChangeTimeoutMs) armed at L666 (7.13.0 L217) is still pending;
    • sets latestNavigationSpan = undefined at L749 (7.13.0 L244) and returns, leaving the root span unnamed and unattributed.

    main added a discard here for POP_TO specifically (L741-L746, from withAnchor's internal POP_TO bookkeeping dispatch creates a spurious duplicate navigation span #6434). A plain NAVIGATE falls through it.

  3. Clearing the handle disarms the SDK's own discard. ignoreEmptyRouteChangeTransactions is installed at L651 (7.13.0 L206-L207) with isSpanStillTracked = () => latestNavigationSpan === spanToCheck. That predicate is exactly what step 2 falsifies, so shouldDiscardFn in onSpanEndUtils.ts returns false and the empty transaction is sent. This looks unintentional rather than deliberate: the check was added in #5387 for the getCurrentRoute() === undefined case (#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.

  4. ignoreEmptyBackNavigation cannot cover it either, because it discards only when route.has_been_seen === true, and that attribute is set in the naming block the early return skipped.

  5. The stray timeout then fires with nothing left to discard. _discardLatestTransaction runs at routeChangeTimeoutMs, but latestNavigationSpan is already undefined, so the root span is untouched.

    This is where the two versions diverge. On main, _discardLatestTransaction ends navigationProcessingSpan (L859-L865), so the child closes at ~routeChangeTimeoutMs and 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's childSpanTimeout (15000 ms, tracing/idleSpan.js) ends it with heartbeatFailed. 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

Projects

Status
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions