Skip to content

Conversation

@onurtemizkan
Copy link
Collaborator

@onurtemizkan onurtemizkan commented Nov 5, 2025

Fixes an issue where consecutive navigations to different routes fail to create separate navigation spans, causing span leaks and missing transaction data.

This came up in a React Router v6/v7 application where the pageload / navigation transactions take longer and there is a high finalTimeout set in config. When users navigate between different routes (e.g., /users/:id/projects/:projectId/settings). The SDK was incorrectly preventing new navigation spans from being created whenever an ongoing navigation span was active, regardless of whether the navigation was to a different route. This resulted in only the first navigation being tracked, with subsequent navigations being silently ignored. Also, the spans that needed to be a part of the subsequent navigation were recorded as a part of the previous one.

The root cause was the if (!isAlreadyInNavigationSpan) check that we used to prevent cross-usage scenarios (multiple wrappers instrumenting the same navigation), which incorrectly blocked legitimate consecutive navigations to different routes.

So, this fix changes the logic to check both navigation span state and the route name: isSpanForSameRoute = isAlreadyInNavigationSpan && spanJson?.description === name. This allows consecutive navigations to different routes while preventing duplicate spans for the same route.

Also added tracking using LAST_NAVIGATION_PER_CLIENT. When multiple wrappers (e.g., wrapCreateBrowserRouter + wrapUseRoutes) instrument the same application, they may each trigger span creation for the same navigation event. We store the navigation key ${location.pathname}${location.search}${location.hash} while the span is active and clear it when that span ends.

If the same navigation key shows up again before the original span finishes, the second wrapper updates that span’s name if it has better parameterization instead of creating a duplicate, which keeps cross-usage covered.

@onurtemizkan onurtemizkan force-pushed the onur/fix-react-router-navigation-leak branch from 63ebcd0 to d720cad Compare November 5, 2025 12:32
@github-actions
Copy link
Contributor

github-actions bot commented Nov 5, 2025

size-limit report 📦

Path Size % Change Change
@sentry/browser 24.6 kB - -
@sentry/browser - with treeshaking flags 23.09 kB - -
@sentry/browser (incl. Tracing) 41.23 kB - -
@sentry/browser (incl. Tracing, Profiling) 45.51 kB - -
@sentry/browser (incl. Tracing, Replay) 79.7 kB - -
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 69.38 kB - -
@sentry/browser (incl. Tracing, Replay with Canvas) 84.39 kB - -
@sentry/browser (incl. Tracing, Replay, Feedback) 96.57 kB - -
@sentry/browser (incl. Feedback) 41.27 kB - -
@sentry/browser (incl. sendFeedback) 29.27 kB - -
@sentry/browser (incl. FeedbackAsync) 34.2 kB - -
@sentry/react 26.29 kB - -
@sentry/react (incl. Tracing) 43.2 kB +0.01% +2 B 🔺
@sentry/vue 29.08 kB - -
@sentry/vue (incl. Tracing) 43.01 kB - -
@sentry/svelte 24.61 kB - -
CDN Bundle 26.9 kB - -
CDN Bundle (incl. Tracing) 41.78 kB - -
CDN Bundle (incl. Tracing, Replay) 78.3 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) 83.78 kB - -
CDN Bundle - uncompressed 78.86 kB - -
CDN Bundle (incl. Tracing) - uncompressed 123.96 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 239.99 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 252.75 kB - -
@sentry/nextjs (client) 45.32 kB - -
@sentry/sveltekit (client) 41.62 kB - -
@sentry/node-core 50.77 kB - -
@sentry/node 157.85 kB +0.01% +1 B 🔺
@sentry/node - without tracing 92.64 kB - -
@sentry/aws-serverless 106.41 kB - -

View base workflow run

@onurtemizkan onurtemizkan force-pushed the onur/fix-react-router-navigation-leak branch 4 times, most recently from cb20156 to f703ae9 Compare November 10, 2025 22:05
@github-actions
Copy link
Contributor

github-actions bot commented Nov 10, 2025

node-overhead report 🧳

Note: This is a synthetic benchmark with a minimal express app and does not necessarily reflect the real-world performance impact in an application.

Scenario Requests/s % of Baseline Prev. Requests/s Change %
GET Baseline 9,568 - 8,690 +10%
GET With Sentry 1,386 14% 1,397 -1%
GET With Sentry (error only) 6,104 64% 6,120 -0%
POST Baseline 1,200 - 1,213 -1%
POST With Sentry 516 43% 561 -8%
POST With Sentry (error only) 1,063 89% 1,072 -1%
MYSQL Baseline 3,370 - 3,363 +0%
MYSQL With Sentry 428 13% 482 -11%
MYSQL With Sentry (error only) 2,727 81% 2,713 +1%

View base workflow run

@onurtemizkan onurtemizkan force-pushed the onur/fix-react-router-navigation-leak branch from f703ae9 to 23f63b6 Compare November 11, 2025 10:00
@onurtemizkan onurtemizkan marked this pull request as ready for review November 11, 2025 12:52
@onurtemizkan onurtemizkan force-pushed the onur/fix-react-router-navigation-leak branch from 59e8f76 to 6dbf27e Compare November 11, 2025 12:52
Comment on lines 721 to 731
source,
version,
location,
routes,
basename,
allRoutes,
navigationKey: currentNavigationKey,
});

// Patch navigation span to handle early cancellation (e.g., document.hidden)
if (navigationSpan) {
patchNavigationSpanEnd(navigationSpan, location, routes, basename, allRoutes);
}
} else if (isNavDuplicate && isAlreadyInNavigationSpan && activeSpan) {
tryUpdateSpanName(activeSpan, spanJson?.description, name, source);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Stale LAST_NAVIGATION_PER_CLIENT entry can cause subsequent navigations to be silently ignored if span creation fails.
Severity: HIGH | Confidence: 0.90

🔍 Detailed Analysis

If startBrowserTracingNavigationSpan() returns undefined (e.g., if no idle span is created/set), the LAST_NAVIGATION_PER_CLIENT entry is still set, but the cleanup listener is not registered. This leaves a stale timestamp entry in LAST_NAVIGATION_PER_CLIENT. Consequently, a subsequent navigation to the same location within 100ms will be incorrectly identified as a duplicate by isDuplicateNavigation() and silently ignored, leading to missing navigation spans.

💡 Suggested Fix

Ensure LAST_NAVIGATION_PER_CLIENT entries are always cleaned up, even if startBrowserTracingNavigationSpan() returns undefined, or modify isDuplicateNavigation() to account for cases where no span was successfully created for the initial navigation.

🤖 Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: packages/react/src/reactrouter-compat-utils/instrumentation.tsx#L717-L731

Potential issue: If `startBrowserTracingNavigationSpan()` returns `undefined` (e.g., if
no idle span is created/set), the `LAST_NAVIGATION_PER_CLIENT` entry is still set, but
the cleanup listener is not registered. This leaves a stale timestamp entry in
`LAST_NAVIGATION_PER_CLIENT`. Consequently, a subsequent navigation to the same location
within 100ms will be incorrectly identified as a duplicate by `isDuplicateNavigation()`
and silently ignored, leading to missing navigation spans.

Did we get this right? 👍 / 👎 to inform future reviews.

@onurtemizkan onurtemizkan force-pushed the onur/fix-react-router-navigation-leak branch from eedce19 to 11d1a6a Compare November 11, 2025 16:32
Copy link
Member

@s1gr1d s1gr1d left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is good to go, I just have some minor comments

transactionEvent.contexts?.trace?.op === 'navigation' &&
transactionEvent.transaction === '/another-lazy/sub/:id/:subId'
);
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could move the waitForTransaction calls to the beginning of the test and then do the .locator and .click calls for the navigations right after one another.

I am not sure how long the test takes in between the calls right now, so you can also leave it like it is if you think it's already very fast.

Comment on lines 407 to 411
const shouldHandleNavigation =
state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete);
(state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete)) &&
state.navigation.state === 'idle';

if (shouldHandleNavigation) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this value can be gathered from a function if(shouldHandleNavigation(state, isInitialPageLoadComplete))

As this code is already used two times

newName: string,
newSource: string,
): void {
const isNewNameBetter = newName !== currentSpanName && newName.includes(':');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can name this differently to indicate what "better" means :D
Something like isNewNameParametrized or similar

@s1gr1d s1gr1d requested a review from Copilot November 12, 2025 12:40
Copilot finished reviewing on behalf of s1gr1d November 12, 2025 12:44
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR fixes a critical bug where consecutive navigations to different routes in React Router v6/v7 applications failed to create separate navigation spans, causing span leaks and missing transaction data. The fix introduces a navigation key tracking mechanism to distinguish between cross-usage scenarios (multiple wrappers instrumenting the same navigation) and legitimate consecutive navigations to different routes.

Key Changes:

  • Introduced LAST_NAVIGATION_PER_CLIENT WeakMap to track active navigation keys per client
  • Modified navigation span creation logic to compare navigation keys (pathname + search + hash) instead of just checking for active navigation spans
  • Refactored getNormalizedName function in utils.ts for better readability using early returns/continues
  • Added condition to only handle navigation when router state is 'idle' to prevent creating spans for incomplete navigations

Reviewed Changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/react/src/reactrouter-compat-utils/instrumentation.tsx Core implementation: added LAST_NAVIGATION_PER_CLIENT tracking, navigation key comparison logic, and helper functions for duplicate detection and span name updates
packages/react/src/reactrouter-compat-utils/utils.ts Refactored getNormalizedName function to use early continue/return patterns for improved readability and added getFallbackTransactionName helper
packages/react/test/reactrouterv6.test.tsx Added allRoutes.clear() in beforeEach for test isolation
packages/react/test/reactrouter-descendant-routes.test.tsx Added allRoutes.clear() in beforeEach for test isolation
packages/react/test/reactrouter-cross-usage.test.tsx Updated cross-usage test expectations to verify span updates instead of duplicate span creation, and added comprehensive tests for consecutive navigations scenarios
dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts Added e2e test verifying separate transactions are created for rapid consecutive navigations with unique trace and span IDs

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

newName: string,
newSource: string,
): void {
const isNewNameBetter = newName !== currentSpanName && newName.includes(':');
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition newName.includes(':') may not correctly identify all parameterized routes. For example:

  • Routes with regex patterns like /user/[0-9]+ won't be detected
  • Routes with wildcard patterns like /posts/* contain : but aren't necessarily "better" parameterized names
  • Non-parameterized routes like /api/v1:stable that legitimately contain : would trigger false positives

Consider a more robust check, such as verifying the route contains React Router parameter syntax specifically (e.g., checking for patterns like /:param/ or ending with /:param).

Suggested change
const isNewNameBetter = newName !== currentSpanName && newName.includes(':');
const isReactRouterParam = /\/:[a-zA-Z0-9_]+/.test(newName);
const isNewNameBetter = newName !== currentSpanName && isReactRouterParam;

Copilot uses AI. Check for mistakes.
* Adds resolved routes as children to the parent route.
* Prevents duplicate routes by checking if they already exist.
* Tracks last navigation per client to prevent duplicate spans in cross-usage scenarios.
* Entry persists until next different navigation, handling delayed wrapper execution.
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment "Entry persists until next different navigation" is slightly misleading. Looking at the implementation in createNavigationSpan (lines 663-672), the entry is cleared when the navigation span ends (via the spanEnd event), not when the "next different navigation" occurs. Consider rephrasing to: "Entry persists until the navigation span ends, allowing cross-usage detection during delayed wrapper execution."

Suggested change
* Entry persists until next different navigation, handling delayed wrapper execution.
* Entry persists until the navigation span ends, allowing cross-usage detection during delayed wrapper execution.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants