From 809b2b856ebd6753a199273177a316d72c22a833 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 11 Nov 2025 00:30:33 +0000 Subject: [PATCH 01/19] ref(react): Add more guarding against leftover wildcards in lazy route transactions --- .../src/reactrouter-compat-utils/index.ts | 1 + .../instrumentation.tsx | 242 ++++++++++++------ .../src/reactrouter-compat-utils/utils.ts | 5 + .../instrumentation.test.tsx | 40 +++ .../reactrouter-compat-utils/utils.test.ts | 35 +++ yarn.lock | 46 ++++ 6 files changed, 296 insertions(+), 73 deletions(-) diff --git a/packages/react/src/reactrouter-compat-utils/index.ts b/packages/react/src/reactrouter-compat-utils/index.ts index c2b56ec446fb..bb91ba8d3072 100644 --- a/packages/react/src/reactrouter-compat-utils/index.ts +++ b/packages/react/src/reactrouter-compat-utils/index.ts @@ -25,6 +25,7 @@ export { pathEndsWithWildcard, pathIsWildcardAndHasChildren, getNumberOfUrlSegments, + transactionNameHasWildcard, } from './utils'; // Lazy route exports diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index a6e55f1a967c..e950b43d7a5f 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -41,7 +41,7 @@ import type { UseRoutes, } from '../types'; import { checkRouteForAsyncHandler } from './lazy-routes'; -import { initializeRouterUtils, resolveRouteNameAndSource } from './utils'; +import { initializeRouterUtils, resolveRouteNameAndSource, transactionNameHasWildcard } from './utils'; let _useEffect: UseEffect; let _useLocation: UseLocation; @@ -52,6 +52,9 @@ let _enableAsyncRouteHandlers: boolean = false; const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet(); +/** Delay (ms) for lazy route updates to complete before finalizing span names. */ +const LAZY_ROUTE_UPDATE_DELAY_MS = 50; + /** * Adds resolved routes as children to the parent route. * Prevents duplicate routes by checking if they already exist. @@ -102,6 +105,27 @@ type V6CompatibleVersion = '6' | '7'; // only exported for testing purposes export const allRoutes = new Set(); +/** Tracks pending lazy route loads per span to wait before finalizing span names. */ +const pendingLazyRouteLoads = new WeakMap>>(); + +/** Registers a pending lazy route load promise for a span. */ +function trackLazyRouteLoad(span: Span, promise: Promise): void { + let promises = pendingLazyRouteLoads.get(span); + if (!promises) { + promises = new Set(); + pendingLazyRouteLoads.set(span, promises); + } + promises.add(promise); + + // Clean up when promise resolves/rejects + promise.finally(() => { + const currentPromises = pendingLazyRouteLoads.get(span); + if (currentPromises) { + currentPromises.delete(promise); + } + }); +} + /** * Processes resolved routes by adding them to allRoutes and checking for nested async handlers. */ @@ -166,12 +190,18 @@ export function updateNavigationSpan( forceUpdate = false, matchRoutes: MatchRoutes, ): void { - // Check if this span has already been named to avoid multiple updates - // But allow updates if this is a forced update (e.g., when lazy routes are loaded) - const hasBeenNamed = - !forceUpdate && (activeRootSpan as { __sentry_navigation_name_set__?: boolean })?.__sentry_navigation_name_set__; + const spanJson = spanToJSON(activeRootSpan); + const currentName = spanJson.description; - if (!hasBeenNamed) { + // Check if this span has already been named to avoid multiple updates + // But allow updates if: + // 1. This is a forced update (e.g., when lazy routes are loaded) + // 2. The current name has wildcards (incomplete parameterization) + const hasBeenNamed = (activeRootSpan as { __sentry_navigation_name_set__?: boolean })?.__sentry_navigation_name_set__; + const currentNameHasWildcard = currentName && transactionNameHasWildcard(currentName); + const shouldUpdate = forceUpdate || !hasBeenNamed || currentNameHasWildcard; + + if (shouldUpdate && !spanJson.timestamp) { // Get fresh branches for the current location with all loaded routes const currentBranches = matchRoutes(allRoutes, location); const [name, source] = resolveRouteNameAndSource( @@ -182,18 +212,20 @@ export function updateNavigationSpan( '', ); - // Only update if we have a valid name and the span hasn't finished - const spanJson = spanToJSON(activeRootSpan); - if (name && !spanJson.timestamp) { + // Only update if we have a valid name and it's better than what we have + const isImprovement = name && (!currentName || !name.includes('*')); + if (isImprovement) { activeRootSpan.updateName(name); activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); - // Mark this span as having its name set to prevent future updates - addNonEnumerableProperty( - activeRootSpan as { __sentry_navigation_name_set__?: boolean }, - '__sentry_navigation_name_set__', - true, - ); + // Only mark as finalized if the new name doesn't have wildcards + if (!transactionNameHasWildcard(name)) { + addNonEnumerableProperty( + activeRootSpan as { __sentry_navigation_name_set__?: boolean }, + '__sentry_navigation_name_set__', + true, + ); + } } } } @@ -568,6 +600,8 @@ function wrapPatchRoutesOnNavigation( // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access const targetPath = (args as any)?.path; + const activeRootSpan = getActiveRootSpan(); + // For browser router, wrap the patch function to update span during patching if (!isMemoryRouter) { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access @@ -576,10 +610,10 @@ function wrapPatchRoutesOnNavigation( // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access (args as any).patch = (routeId: string, children: RouteObject[]) => { addRoutesToAllRoutes(children); - const activeRootSpan = getActiveRootSpan(); - if (activeRootSpan && (spanToJSON(activeRootSpan) as { op?: string }).op === 'navigation') { + const currentActiveRootSpan = getActiveRootSpan(); + if (currentActiveRootSpan && (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation') { updateNavigationSpan( - activeRootSpan, + currentActiveRootSpan, { pathname: targetPath, search: '', hash: '', state: null, key: 'default' }, Array.from(allRoutes), true, // forceUpdate = true since we're loading lazy routes @@ -591,33 +625,43 @@ function wrapPatchRoutesOnNavigation( } } - const result = await originalPatchRoutes(args); + // Create and track promise for this lazy load + const lazyLoadPromise = (async () => { + const result = await originalPatchRoutes(args); + + // Update navigation span after routes are patched + const currentActiveRootSpan = getActiveRootSpan(); + if (currentActiveRootSpan && (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation') { + // Determine pathname based on router type + let pathname: string | undefined; + if (isMemoryRouter) { + // For memory routers, only use targetPath + pathname = targetPath; + } else { + // For browser routers, use targetPath or fall back to window.location + pathname = targetPath || WINDOW.location?.pathname; + } - // Update navigation span after routes are patched - const activeRootSpan = getActiveRootSpan(); - if (activeRootSpan && (spanToJSON(activeRootSpan) as { op?: string }).op === 'navigation') { - // Determine pathname based on router type - let pathname: string | undefined; - if (isMemoryRouter) { - // For memory routers, only use targetPath - pathname = targetPath; - } else { - // For browser routers, use targetPath or fall back to window.location - pathname = targetPath || WINDOW.location?.pathname; + if (pathname) { + updateNavigationSpan( + currentActiveRootSpan, + { pathname, search: '', hash: '', state: null, key: 'default' }, + Array.from(allRoutes), + false, // forceUpdate = false since this is after lazy routes are loaded + _matchRoutes, + ); + } } - if (pathname) { - updateNavigationSpan( - activeRootSpan, - { pathname, search: '', hash: '', state: null, key: 'default' }, - Array.from(allRoutes), - false, // forceUpdate = false since this is after lazy routes are loaded - _matchRoutes, - ); - } + return result; + })(); + + // Track the promise if we have an active span + if (activeRootSpan) { + trackLazyRouteLoad(activeRootSpan, lazyLoadPromise); } - return result; + return lazyLoadPromise; }, }; } @@ -658,9 +702,20 @@ export function handleNavigation(opts: { const activeSpan = getActiveSpan(); const spanJson = activeSpan && spanToJSON(activeSpan); const isAlreadyInNavigationSpan = spanJson?.op === 'navigation'; + const currentName = spanJson?.description; + + // If we're already in a navigation span, check if we should update its name + if (isAlreadyInNavigationSpan && activeSpan) { + // Only update if the new name is better (doesn't have wildcards or is more complete) + const shouldUpdate = currentName && transactionNameHasWildcard(currentName) && !transactionNameHasWildcard(name); - // Cross usage can result in multiple navigation spans being created without this check - if (!isAlreadyInNavigationSpan) { + if (shouldUpdate) { + activeSpan.updateName(name); + activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source as 'route' | 'url' | 'custom'); + DEBUG_BUILD && debug.log(`[Tracing] Updated navigation span name from "${currentName}" to "${name}"`); + } + } else if (!isAlreadyInNavigationSpan) { + // Cross usage can result in multiple navigation spans being created without this check const navigationSpan = startBrowserTracingNavigationSpan(client, { name, attributes: { @@ -741,6 +796,50 @@ function updatePageloadTransaction({ } } +/** Updates span name before end using latest route information. */ +function tryUpdateSpanNameBeforeEnd( + span: Span, + spanJson: ReturnType, + currentName: string | undefined, + location: Location, + routes: RouteObject[], + basename: string | undefined, + spanType: 'pageload' | 'navigation', + allRoutes: Set, +): void { + try { + const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; + const hasWildcard = currentName && transactionNameHasWildcard(currentName); + + // Only attempt update if source is not 'route' or if the name has wildcards + if (currentSource === 'route' && !hasWildcard) { + return; + } + + const currentAllRoutes = Array.from(allRoutes); + const routesToUse = currentAllRoutes.length > 0 ? currentAllRoutes : routes; + const branches = _matchRoutes(routesToUse, location, basename) as unknown as RouteMatch[]; + + if (!branches) { + return; + } + + const [name, source] = resolveRouteNameAndSource(location, routesToUse, routesToUse, branches, basename); + + // Only update if we have a valid name and it's better than current + const isImprovement = name && (!currentName || hasWildcard); + const spanNotEnded = spanType === 'pageload' || !spanJson.timestamp; + + if (isImprovement && spanNotEnded) { + span.updateName(name); + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); + } + } catch (error) { + // Silently catch errors to ensure span.end() is always called + DEBUG_BUILD && debug.warn(`Error updating span details before ending: ${error}`); + } +} + /** * Patches the span.end() method to update the transaction name one last time before the span is sent. * This handles cases where the span is cancelled early (e.g., document.hidden) before lazy routes have finished loading. @@ -762,42 +861,39 @@ function patchSpanEnd( const originalEnd = span.end.bind(span); + let endCalled = false; + span.end = function patchedEnd(...args) { - try { - // Only update if the span source is not already 'route' (i.e., it hasn't been parameterized yet) - const spanJson = spanToJSON(span); - const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; - if (currentSource !== 'route') { - // Last chance to update the transaction name with the latest route info - // Use the live global allRoutes Set to include any lazy routes loaded after patching - const currentAllRoutes = Array.from(allRoutes); - const branches = _matchRoutes( - currentAllRoutes.length > 0 ? currentAllRoutes : routes, + // Prevent multiple calls to end() + if (endCalled) { + return; + } + endCalled = true; + + const spanJson = spanToJSON(span); + const currentName = spanJson.description; + + // If we have pending lazy route loads and the current name has wildcards, delay the end slightly + const pendingPromises = pendingLazyRouteLoads.get(span); + if (pendingPromises && pendingPromises.size > 0 && currentName && transactionNameHasWildcard(currentName)) { + // Small delay to allow in-flight lazy routes to complete + setTimeout(() => { + tryUpdateSpanNameBeforeEnd( + span, + spanToJSON(span), + spanToJSON(span).description, location, + routes, basename, - ) as unknown as RouteMatch[]; - - if (branches) { - const [name, source] = resolveRouteNameAndSource( - location, - routes, - currentAllRoutes.length > 0 ? currentAllRoutes : routes, - branches, - basename, - ); - - // Only update if we have a valid name - if (name && (spanType === 'pageload' || !spanJson.timestamp)) { - span.updateName(name); - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); - } - } - } - } catch (error) { - // Silently catch errors to ensure span.end() is always called - DEBUG_BUILD && debug.warn(`Error updating span details before ending: ${error}`); + spanType, + allRoutes, + ); + originalEnd(...args); + }, LAZY_ROUTE_UPDATE_DELAY_MS); + return; } + tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutes); return originalEnd(...args); }; diff --git a/packages/react/src/reactrouter-compat-utils/utils.ts b/packages/react/src/reactrouter-compat-utils/utils.ts index d6501d0e4dbf..9de0cfe88d38 100644 --- a/packages/react/src/reactrouter-compat-utils/utils.ts +++ b/packages/react/src/reactrouter-compat-utils/utils.ts @@ -38,6 +38,11 @@ export function pathEndsWithWildcard(path: string): boolean { return path.endsWith('*'); } +/** Checks if transaction name has wildcard (/* or * or ends with *). */ +export function transactionNameHasWildcard(name: string): boolean { + return name.includes('/*') || name === '*' || name.endsWith('*'); +} + /** * Checks if a path is a wildcard and has child routes. */ diff --git a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx index bad264d3d6b5..e6cf36f8883c 100644 --- a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx +++ b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx @@ -49,6 +49,9 @@ vi.mock('../../src/reactrouter-compat-utils/utils', () => ({ getGlobalLocation: vi.fn(() => ({ pathname: '/test', search: '', hash: '' })), getGlobalPathname: vi.fn(() => '/test'), routeIsDescendant: vi.fn(() => false), + transactionNameHasWildcard: vi.fn((name: string) => { + return name.includes('/*') || name === '*' || name.endsWith('*'); + }), })); vi.mock('../../src/reactrouter-compat-utils/lazy-routes', () => ({ @@ -370,3 +373,40 @@ describe('addRoutesToAllRoutes', () => { expect(firstCount).toBe(secondCount); }); }); + +describe('updateNavigationSpan with wildcard detection', () => { + const sampleLocation: Location = { + pathname: '/test', + search: '', + hash: '', + state: null, + key: 'default', + }; + + const sampleRoutes: RouteObject[] = [ + { path: '/', element:
Home
}, + { path: '/about', element:
About
}, + ]; + + const mockMatchRoutes = vi.fn(() => []); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call updateName when provided with valid routes', () => { + const testSpan = { ...mockSpan }; + updateNavigationSpan(testSpan, sampleLocation, sampleRoutes, false, mockMatchRoutes); + + expect(mockUpdateName).toHaveBeenCalledWith('Test Route'); + expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route'); + }); + + it('should handle forced updates', () => { + const testSpan = { ...mockSpan, __sentry_navigation_name_set__: true }; + updateNavigationSpan(testSpan, sampleLocation, sampleRoutes, true, mockMatchRoutes); + + // Should update even though already named because forceUpdate=true + expect(mockUpdateName).toHaveBeenCalledWith('Test Route'); + }); +}); diff --git a/packages/react/test/reactrouter-compat-utils/utils.test.ts b/packages/react/test/reactrouter-compat-utils/utils.test.ts index 9ff48e7450bc..438b026104bd 100644 --- a/packages/react/test/reactrouter-compat-utils/utils.test.ts +++ b/packages/react/test/reactrouter-compat-utils/utils.test.ts @@ -9,6 +9,7 @@ import { prefixWithSlash, rebuildRoutePathFromAllRoutes, resolveRouteNameAndSource, + transactionNameHasWildcard, } from '../../src/reactrouter-compat-utils'; import type { Location, MatchRoutes, RouteMatch, RouteObject } from '../../src/types'; @@ -629,4 +630,38 @@ describe('reactrouter-compat-utils/utils', () => { expect(result).toEqual(['/unknown', 'url']); }); }); + + describe('transactionNameHasWildcard', () => { + it('should detect wildcard at the end of path', () => { + expect(transactionNameHasWildcard('/lazy/*')).toBe(true); + expect(transactionNameHasWildcard('/users/:id/*')).toBe(true); + expect(transactionNameHasWildcard('/products/:category/*')).toBe(true); + }); + + it('should detect standalone wildcard', () => { + expect(transactionNameHasWildcard('*')).toBe(true); + }); + + it('should detect wildcard in the middle of path', () => { + expect(transactionNameHasWildcard('/lazy/*/nested')).toBe(true); + expect(transactionNameHasWildcard('/a/*/b/*/c')).toBe(true); + }); + + it('should not detect wildcards in parameterized routes', () => { + expect(transactionNameHasWildcard('/users/:id')).toBe(false); + expect(transactionNameHasWildcard('/products/:category/:id')).toBe(false); + expect(transactionNameHasWildcard('/items/:itemId/details')).toBe(false); + }); + + it('should not detect wildcards in static routes', () => { + expect(transactionNameHasWildcard('/')).toBe(false); + expect(transactionNameHasWildcard('/about')).toBe(false); + expect(transactionNameHasWildcard('/users/profile')).toBe(false); + }); + + it('should handle edge cases', () => { + expect(transactionNameHasWildcard('')).toBe(false); + expect(transactionNameHasWildcard('/path/to/asterisk')).toBe(false); // 'asterisk' contains 'isk' but not '*' + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 0b2a0f0f31dc..3f8504411f48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6970,6 +6970,20 @@ "@angular-devkit/schematics" "14.2.13" jsonc-parser "3.1.0" +"@sentry-internal/browser-utils@10.23.0": + version "10.23.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-10.23.0.tgz#738a07ed99168cdf69d0cdb5a152289ed049de81" + integrity sha512-FUak8FH51TnGrx2i31tgqun0VsbDCVQS7dxWnUZHdi+0hpnFoq9+wBHY+qrOQjaInZSz3crIifYv3z7SEzD0Jg== + dependencies: + "@sentry/core" "10.23.0" + +"@sentry-internal/feedback@10.23.0": + version "10.23.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-10.23.0.tgz#4b9ade29f1d96309eea83cc513c4a73e3992c4d7" + integrity sha512-+HWC9VTPICsFX/lIPoBU9GxTaJZVXJcukP+qGxj+j/8q/Dy1w22JHDWcJbZiaW4kWWlz7VbA0KVKS3grD+e9aA== + dependencies: + "@sentry/core" "10.23.0" + "@sentry-internal/node-cpu-profiler@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@sentry-internal/node-cpu-profiler/-/node-cpu-profiler-2.2.0.tgz#0640d4aebb4d36031658ccff83dc22b76f437ede" @@ -6986,6 +7000,22 @@ detect-libc "^2.0.4" node-abi "^3.73.0" +"@sentry-internal/replay-canvas@10.23.0": + version "10.23.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-10.23.0.tgz#236916fb9d40637d8c9f86c52b2b1619b1170854" + integrity sha512-GLNY8JPcMI6xhQ5FHiYO/W/3flrwZMt4CI/E3jDRNujYWbCrca60MRke6k7Zm1qi9rZ1FuhVWZ6BAFc4vwXnSg== + dependencies: + "@sentry-internal/replay" "10.23.0" + "@sentry/core" "10.23.0" + +"@sentry-internal/replay@10.23.0": + version "10.23.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-10.23.0.tgz#7a6075e2c2e1d0a371764d7c2e5dad578bb7b1fe" + integrity sha512-5yPD7jVO2JY8+JEHXep0Bf/ugp4rmxv5BkHIcSAHQsKSPhziFks2x+KP+6M8hhbF1WydqAaDYlGjrkL2yspHqA== + dependencies: + "@sentry-internal/browser-utils" "10.23.0" + "@sentry/core" "10.23.0" + "@sentry-internal/rrdom@2.34.0": version "2.34.0" resolved "https://registry.yarnpkg.com/@sentry-internal/rrdom/-/rrdom-2.34.0.tgz#fccc9fe211c3995d4200abafbe8d75b671961ee9" @@ -7059,6 +7089,17 @@ resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.3.0.tgz#c5b6cbb986952596d3ad233540a90a1fd18bad80" integrity sha512-OuxqBprXRyhe8Pkfyz/4yHQJc5c3lm+TmYWSSx8u48g5yKewSQDOxkiLU5pAk3WnbLPy8XwU/PN+2BG0YFU9Nw== +"@sentry/browser@10.23.0": + version "10.23.0" + resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-10.23.0.tgz#aa85f9c21c9a6c80b8952ee15307997fb34edbb3" + integrity sha512-9hViLfYONxRJykOhJQ3ZHQ758t1wQIsxEC7mTsydbDm+m12LgbBtXbfgcypWHlom5Yvb+wg6W+31bpdGnATglw== + dependencies: + "@sentry-internal/browser-utils" "10.23.0" + "@sentry-internal/feedback" "10.23.0" + "@sentry-internal/replay" "10.23.0" + "@sentry-internal/replay-canvas" "10.23.0" + "@sentry/core" "10.23.0" + "@sentry/bundler-plugin-core@4.3.0", "@sentry/bundler-plugin-core@^4.3.0": version "4.3.0" resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.3.0.tgz#cf302522a3e5b8a3bf727635d0c6a7bece981460" @@ -7133,6 +7174,11 @@ "@sentry/cli-win32-i686" "2.56.0" "@sentry/cli-win32-x64" "2.56.0" +"@sentry/core@10.23.0": + version "10.23.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-10.23.0.tgz#7d4eb4d2c7b9ecc88872975a916f44e0b9fec78a" + integrity sha512-4aZwu6VnSHWDplY5eFORcVymhfvS/P6BRfK81TPnG/ReELaeoykKjDwR+wC4lO7S0307Vib9JGpszjsEZw245g== + "@sentry/rollup-plugin@^4.3.0": version "4.3.0" resolved "https://registry.yarnpkg.com/@sentry/rollup-plugin/-/rollup-plugin-4.3.0.tgz#d23fe49e48fa68dafa2b0933a8efabcc964b1df9" From 3f10bf60a177d2dfff5a429d034d176156f393f5 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 11 Nov 2025 11:58:36 +0000 Subject: [PATCH 02/19] Fix lockfile --- yarn.lock | 46 ---------------------------------------------- 1 file changed, 46 deletions(-) diff --git a/yarn.lock b/yarn.lock index 3f8504411f48..0b2a0f0f31dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6970,20 +6970,6 @@ "@angular-devkit/schematics" "14.2.13" jsonc-parser "3.1.0" -"@sentry-internal/browser-utils@10.23.0": - version "10.23.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-10.23.0.tgz#738a07ed99168cdf69d0cdb5a152289ed049de81" - integrity sha512-FUak8FH51TnGrx2i31tgqun0VsbDCVQS7dxWnUZHdi+0hpnFoq9+wBHY+qrOQjaInZSz3crIifYv3z7SEzD0Jg== - dependencies: - "@sentry/core" "10.23.0" - -"@sentry-internal/feedback@10.23.0": - version "10.23.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-10.23.0.tgz#4b9ade29f1d96309eea83cc513c4a73e3992c4d7" - integrity sha512-+HWC9VTPICsFX/lIPoBU9GxTaJZVXJcukP+qGxj+j/8q/Dy1w22JHDWcJbZiaW4kWWlz7VbA0KVKS3grD+e9aA== - dependencies: - "@sentry/core" "10.23.0" - "@sentry-internal/node-cpu-profiler@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@sentry-internal/node-cpu-profiler/-/node-cpu-profiler-2.2.0.tgz#0640d4aebb4d36031658ccff83dc22b76f437ede" @@ -7000,22 +6986,6 @@ detect-libc "^2.0.4" node-abi "^3.73.0" -"@sentry-internal/replay-canvas@10.23.0": - version "10.23.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-10.23.0.tgz#236916fb9d40637d8c9f86c52b2b1619b1170854" - integrity sha512-GLNY8JPcMI6xhQ5FHiYO/W/3flrwZMt4CI/E3jDRNujYWbCrca60MRke6k7Zm1qi9rZ1FuhVWZ6BAFc4vwXnSg== - dependencies: - "@sentry-internal/replay" "10.23.0" - "@sentry/core" "10.23.0" - -"@sentry-internal/replay@10.23.0": - version "10.23.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-10.23.0.tgz#7a6075e2c2e1d0a371764d7c2e5dad578bb7b1fe" - integrity sha512-5yPD7jVO2JY8+JEHXep0Bf/ugp4rmxv5BkHIcSAHQsKSPhziFks2x+KP+6M8hhbF1WydqAaDYlGjrkL2yspHqA== - dependencies: - "@sentry-internal/browser-utils" "10.23.0" - "@sentry/core" "10.23.0" - "@sentry-internal/rrdom@2.34.0": version "2.34.0" resolved "https://registry.yarnpkg.com/@sentry-internal/rrdom/-/rrdom-2.34.0.tgz#fccc9fe211c3995d4200abafbe8d75b671961ee9" @@ -7089,17 +7059,6 @@ resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.3.0.tgz#c5b6cbb986952596d3ad233540a90a1fd18bad80" integrity sha512-OuxqBprXRyhe8Pkfyz/4yHQJc5c3lm+TmYWSSx8u48g5yKewSQDOxkiLU5pAk3WnbLPy8XwU/PN+2BG0YFU9Nw== -"@sentry/browser@10.23.0": - version "10.23.0" - resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-10.23.0.tgz#aa85f9c21c9a6c80b8952ee15307997fb34edbb3" - integrity sha512-9hViLfYONxRJykOhJQ3ZHQ758t1wQIsxEC7mTsydbDm+m12LgbBtXbfgcypWHlom5Yvb+wg6W+31bpdGnATglw== - dependencies: - "@sentry-internal/browser-utils" "10.23.0" - "@sentry-internal/feedback" "10.23.0" - "@sentry-internal/replay" "10.23.0" - "@sentry-internal/replay-canvas" "10.23.0" - "@sentry/core" "10.23.0" - "@sentry/bundler-plugin-core@4.3.0", "@sentry/bundler-plugin-core@^4.3.0": version "4.3.0" resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.3.0.tgz#cf302522a3e5b8a3bf727635d0c6a7bece981460" @@ -7174,11 +7133,6 @@ "@sentry/cli-win32-i686" "2.56.0" "@sentry/cli-win32-x64" "2.56.0" -"@sentry/core@10.23.0": - version "10.23.0" - resolved "https://registry.yarnpkg.com/@sentry/core/-/core-10.23.0.tgz#7d4eb4d2c7b9ecc88872975a916f44e0b9fec78a" - integrity sha512-4aZwu6VnSHWDplY5eFORcVymhfvS/P6BRfK81TPnG/ReELaeoykKjDwR+wC4lO7S0307Vib9JGpszjsEZw245g== - "@sentry/rollup-plugin@^4.3.0": version "4.3.0" resolved "https://registry.yarnpkg.com/@sentry/rollup-plugin/-/rollup-plugin-4.3.0.tgz#d23fe49e48fa68dafa2b0933a8efabcc964b1df9" From efb5a745b4b603106b97986be310f50bbc67c735 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 11 Nov 2025 16:10:34 +0000 Subject: [PATCH 03/19] Add configurable lazy route timeout and fix url paths --- .../instrumentation.tsx | 99 +++++++++- .../instrumentation.test.tsx | 177 +++++++++++++++++- 2 files changed, 267 insertions(+), 9 deletions(-) diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index e950b43d7a5f..2aa538517c8e 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -52,8 +52,8 @@ let _enableAsyncRouteHandlers: boolean = false; const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet(); -/** Delay (ms) for lazy route updates to complete before finalizing span names. */ -const LAZY_ROUTE_UPDATE_DELAY_MS = 50; +// Maximum time (ms) to wait for lazy routes (configured in setup(), default: idleTimeout * 3) +let _maxLazyRouteWaitMs = 3000; /** * Adds resolved routes as children to the parent route. @@ -97,6 +97,15 @@ export interface ReactRouterOptions { * @default false */ enableAsyncRouteHandlers?: boolean; + + /** + * Maximum time (in milliseconds) to wait for lazy routes to load before finalizing span names. + * + * Defaults to 3× the configured `idleTimeout`. Set to `0` to not wait at all. + * + * Default: idleTimeout * 3 + */ + maxLazyRouteWaitMs?: number; } type V6CompatibleVersion = '6' | '7'; @@ -485,6 +494,7 @@ export function createReactRouterV6CompatibleTracingIntegration( enableAsyncRouteHandlers = false, instrumentPageLoad = true, instrumentNavigation = true, + maxLazyRouteWaitMs, } = options; return { @@ -492,6 +502,35 @@ export function createReactRouterV6CompatibleTracingIntegration( setup(client) { integration.setup(client); + // Get idleTimeout from browserTracingIntegration options (passed through) + // idleTimeout from browserTracingIntegration (default: 1000ms) + // Note: options already contains idleTimeout if user passed it to browserTracingIntegration + const idleTimeout = options.idleTimeout ?? 1000; + + // Calculate default: 3× idleTimeout + const defaultMaxWait = idleTimeout * 3; + + // Allow explicit override, otherwise use calculated default + const configuredMaxWait = maxLazyRouteWaitMs ?? defaultMaxWait; + + // Validate and set + if (Number.isNaN(configuredMaxWait)) { + DEBUG_BUILD && + debug.warn('[React Router] maxLazyRouteWaitMs must be a number, falling back to default:', defaultMaxWait); + _maxLazyRouteWaitMs = defaultMaxWait; + } else if (configuredMaxWait < 0 && configuredMaxWait !== Infinity) { + DEBUG_BUILD && + debug.warn( + '[React Router] maxLazyRouteWaitMs must be non-negative or Infinity, got:', + configuredMaxWait, + 'falling back to:', + defaultMaxWait, + ); + _maxLazyRouteWaitMs = defaultMaxWait; + } else { + _maxLazyRouteWaitMs = configuredMaxWait; + } + _useEffect = useEffect; _useLocation = useLocation; _useNavigationType = useNavigationType; @@ -827,7 +866,11 @@ function tryUpdateSpanNameBeforeEnd( const [name, source] = resolveRouteNameAndSource(location, routesToUse, routesToUse, branches, basename); // Only update if we have a valid name and it's better than current - const isImprovement = name && (!currentName || hasWildcard); + // Upgrade conditions: + // 1. No current name exists + // 2. Current name has wildcards (less specific) + // 3. Upgrading from non-route source to route source (e.g., URL -> parameterized route) + const isImprovement = name && (!currentName || hasWildcard || (currentSource !== 'route' && source === 'route')); const spanNotEnded = spanType === 'pageload' || !spanJson.timestamp; if (isImprovement && spanNotEnded) { @@ -873,11 +916,13 @@ function patchSpanEnd( const spanJson = spanToJSON(span); const currentName = spanJson.description; - // If we have pending lazy route loads and the current name has wildcards, delay the end slightly + // If we have pending lazy route loads and the current name has wildcards, + // wait for promises to resolve (with timeout) before finalizing span const pendingPromises = pendingLazyRouteLoads.get(span); if (pendingPromises && pendingPromises.size > 0 && currentName && transactionNameHasWildcard(currentName)) { - // Small delay to allow in-flight lazy routes to complete - setTimeout(() => { + // Special case: 0 means don't wait at all (legacy behavior) + if (_maxLazyRouteWaitMs === 0) { + // Don't wait - immediately update and end span tryUpdateSpanNameBeforeEnd( span, spanToJSON(span), @@ -888,8 +933,46 @@ function patchSpanEnd( spanType, allRoutes, ); - originalEnd(...args); - }, LAZY_ROUTE_UPDATE_DELAY_MS); + return originalEnd(...args); + } + + // Take snapshot of current promises to wait for (prevents race conditions with new navigations) + const promiseArray = Array.from(pendingPromises); + + // Wait for all lazy routes to settle (never rejects, safe for all outcomes) + const settledPromise = Promise.allSettled(promiseArray).then(() => {}); + + // Create timeout promise to prevent hanging indefinitely + const timeoutPromise = new Promise(resolve => { + // Handle special case: Infinity means no timeout + if (_maxLazyRouteWaitMs === Infinity) { + // Don't resolve - wait indefinitely (user explicitly opted in) + return; + } + setTimeout(resolve, _maxLazyRouteWaitMs); + }); + + // Race: whichever completes first (routes resolve or timeout) + Promise.race([settledPromise, timeoutPromise]) + .then(() => { + // Try to update span name with (hopefully) resolved routes + tryUpdateSpanNameBeforeEnd( + span, + spanToJSON(span), + spanToJSON(span).description, + location, + routes, + basename, + spanType, + allRoutes, + ); + originalEnd(...args); + }) + .catch((error: unknown) => { + // Defensive: allSettled never rejects, but be safe + DEBUG_BUILD && debug.warn('Error waiting for lazy routes:', error); + originalEnd(...args); + }); return; } diff --git a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx index e6cf36f8883c..107d499cdf44 100644 --- a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx +++ b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx @@ -2,7 +2,7 @@ * @vitest-environment jsdom */ import type { Client, Span } from '@sentry/core'; -import { addNonEnumerableProperty } from '@sentry/core'; +import { addNonEnumerableProperty, spanToJSON } from '@sentry/core'; import * as React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -11,6 +11,7 @@ import { updateNavigationSpan, } from '../../src/reactrouter-compat-utils'; import { addRoutesToAllRoutes, allRoutes } from '../../src/reactrouter-compat-utils/instrumentation'; +import { resolveRouteNameAndSource, transactionNameHasWildcard } from '../../src/reactrouter-compat-utils/utils'; import type { Location, RouteObject } from '../../src/types'; const mockUpdateName = vi.fn(); @@ -410,3 +411,177 @@ describe('updateNavigationSpan with wildcard detection', () => { expect(mockUpdateName).toHaveBeenCalledWith('Test Route'); }); }); + +describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should upgrade from URL source to route source (regression fix)', async () => { + // Setup: Current span has URL source and non-parameterized name + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: '/users/123', + data: { 'sentry.source': 'url' }, + } as any); + + // Target: Resolves to route source with parameterized name + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/:id', 'route']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + } as unknown as Span; + + // Simulate patchSpanEnd calling tryUpdateSpanNameBeforeEnd + // by updating the span name during a navigation + updateNavigationSpan( + testSpan, + { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/:id', element:
}], + false, + vi.fn(() => [{ route: { path: '/users/:id' } }]), + ); + + // Should upgrade from URL to route source + expect(mockUpdateName).toHaveBeenCalledWith('/users/:id'); + expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route'); + }); + + it('should NOT downgrade from route source to URL source', async () => { + // Setup: Current span has route source with parameterized name (no wildcard) + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: '/users/:id', + data: { 'sentry.source': 'route' }, + } as any); + + // Target: Would resolve to URL source (downgrade attempt) + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/456', 'url']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + __sentry_navigation_name_set__: true, // Mark as already named + } as unknown as Span; + + updateNavigationSpan( + testSpan, + { pathname: '/users/456', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/:id', element:
}], + false, + vi.fn(() => [{ route: { path: '/users/:id' } }]), + ); + + // Should NOT update because span is already named + // The early return in tryUpdateSpanNameBeforeEnd (line 815) protects against downgrades + // This test verifies that route->url downgrades are blocked + expect(mockUpdateName).not.toHaveBeenCalled(); + expect(mockSetAttribute).not.toHaveBeenCalled(); + }); + + it('should upgrade wildcard names to specific routes', async () => { + // Setup: Current span has route source with wildcard + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: '/users/*', + data: { 'sentry.source': 'route' }, + } as any); + + // Mock wildcard detection + vi.mocked(transactionNameHasWildcard).mockReturnValue(true); + + // Target: Resolves to specific parameterized route + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/:id', 'route']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + } as unknown as Span; + + updateNavigationSpan( + testSpan, + { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/:id', element:
}], + false, + vi.fn(() => [{ route: { path: '/users/:id' } }]), + ); + + // Should upgrade from wildcard to specific + expect(mockUpdateName).toHaveBeenCalledWith('/users/:id'); + expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route'); + }); + + it('should set name when no current name exists', async () => { + // Setup: Current span has no name (undefined) + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: undefined, + } as any); + + // Target: Resolves to route + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/:id', 'route']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + } as unknown as Span; + + updateNavigationSpan( + testSpan, + { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/:id', element:
}], + false, + vi.fn(() => [{ route: { path: '/users/:id' } }]), + ); + + // Should set initial name + expect(mockUpdateName).toHaveBeenCalledWith('/users/:id'); + expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route'); + }); + + it('should not update when same source and no improvement', async () => { + // Setup: Current span has URL source + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: '/users/123', + data: { 'sentry.source': 'url' }, + } as any); + + // Target: Resolves to same URL source (no improvement) + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/123', 'url']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + } as unknown as Span; + + updateNavigationSpan( + testSpan, + { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/:id', element:
}], + false, + vi.fn(() => [{ route: { path: '/users/:id' } }]), + ); + + // Note: updateNavigationSpan always updates if not already named + // This test validates that the isImprovement logic works correctly in tryUpdateSpanNameBeforeEnd + // which is called during span.end() patching + expect(mockUpdateName).toHaveBeenCalled(); // Initial set is allowed + }); +}); From 0bb2bc40175a1b4e1cf8dac96582d51dd5f31ad6 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 11 Nov 2025 16:30:52 +0000 Subject: [PATCH 04/19] Optimize for bundle size --- .../instrumentation.tsx | 36 +++++++------------ 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index 2aa538517c8e..d66eecae9908 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -505,12 +505,8 @@ export function createReactRouterV6CompatibleTracingIntegration( // Get idleTimeout from browserTracingIntegration options (passed through) // idleTimeout from browserTracingIntegration (default: 1000ms) // Note: options already contains idleTimeout if user passed it to browserTracingIntegration - const idleTimeout = options.idleTimeout ?? 1000; - - // Calculate default: 3× idleTimeout - const defaultMaxWait = idleTimeout * 3; - - // Allow explicit override, otherwise use calculated default + // Calculate default: 3× idleTimeout, allow explicit override + const defaultMaxWait = (options.idleTimeout ?? 1000) * 3; const configuredMaxWait = maxLazyRouteWaitMs ?? defaultMaxWait; // Validate and set @@ -940,22 +936,17 @@ function patchSpanEnd( const promiseArray = Array.from(pendingPromises); // Wait for all lazy routes to settle (never rejects, safe for all outcomes) - const settledPromise = Promise.allSettled(promiseArray).then(() => {}); - - // Create timeout promise to prevent hanging indefinitely - const timeoutPromise = new Promise(resolve => { - // Handle special case: Infinity means no timeout - if (_maxLazyRouteWaitMs === Infinity) { - // Don't resolve - wait indefinitely (user explicitly opted in) - return; - } - setTimeout(resolve, _maxLazyRouteWaitMs); - }); + const allSettled = Promise.allSettled(promiseArray).then(() => {}); + + // Race against timeout or wait indefinitely if Infinity + const waitPromise = + _maxLazyRouteWaitMs === Infinity + ? allSettled + : Promise.race([allSettled, new Promise(r => setTimeout(r, _maxLazyRouteWaitMs))]); - // Race: whichever completes first (routes resolve or timeout) - Promise.race([settledPromise, timeoutPromise]) + // Update span name once routes are resolved or timeout expires + waitPromise .then(() => { - // Try to update span name with (hopefully) resolved routes tryUpdateSpanNameBeforeEnd( span, spanToJSON(span), @@ -968,9 +959,8 @@ function patchSpanEnd( ); originalEnd(...args); }) - .catch((error: unknown) => { - // Defensive: allSettled never rejects, but be safe - DEBUG_BUILD && debug.warn('Error waiting for lazy routes:', error); + .catch(() => { + // Defensive: should never happen with allSettled, but satisfy ESLint originalEnd(...args); }); return; From 97bb7cce89c10a91f86ee7e1d7714f413ad3d2cd Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Wed, 12 Nov 2025 09:35:22 +0000 Subject: [PATCH 05/19] Do not downgrade from wildcard route to unparameterized route --- .../instrumentation.tsx | 17 +++++-- .../instrumentation.test.tsx | 49 +++++++++++++++++-- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index d66eecae9908..cb87d8394764 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -222,7 +222,14 @@ export function updateNavigationSpan( ); // Only update if we have a valid name and it's better than what we have - const isImprovement = name && (!currentName || !name.includes('*')); + // Never downgrade from route source to url source + const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; + const isImprovement = + name && + (!hasBeenNamed || // Span not finalized - accept any name + !currentName || // No current name - always set + (currentNameHasWildcard && source === 'route') || // Wildcard route → better route (MUST stay in route source) + (currentSource !== 'route' && source === 'route')); // URL → route upgrade if (isImprovement) { activeRootSpan.updateName(name); activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); @@ -864,9 +871,13 @@ function tryUpdateSpanNameBeforeEnd( // Only update if we have a valid name and it's better than current // Upgrade conditions: // 1. No current name exists - // 2. Current name has wildcards (less specific) + // 2. Current name has wildcards and new source is also 'route' (never downgrade route→url) // 3. Upgrading from non-route source to route source (e.g., URL -> parameterized route) - const isImprovement = name && (!currentName || hasWildcard || (currentSource !== 'route' && source === 'route')); + const isImprovement = + name && + (!currentName || // No current name - always set + (hasWildcard && source === 'route') || // Wildcard route → better route (MUST stay in route source) + (currentSource !== 'route' && source === 'route')); // URL → route upgrade const spanNotEnded = spanType === 'pageload' || !spanJson.timestamp; if (isImprovement && spanNotEnded) { diff --git a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx index 107d499cdf44..c1626637daee 100644 --- a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx +++ b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx @@ -451,7 +451,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route'); }); - it('should NOT downgrade from route source to URL source', async () => { + it('should not downgrade from route source to URL source', async () => { // Setup: Current span has route source with parameterized name (no wildcard) vi.mocked(spanToJSON).mockReturnValue({ op: 'navigation', @@ -479,7 +479,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { vi.fn(() => [{ route: { path: '/users/:id' } }]), ); - // Should NOT update because span is already named + // Should not update because span is already named // The early return in tryUpdateSpanNameBeforeEnd (line 815) protects against downgrades // This test verifies that route->url downgrades are blocked expect(mockUpdateName).not.toHaveBeenCalled(); @@ -494,8 +494,10 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { data: { 'sentry.source': 'route' }, } as any); - // Mock wildcard detection - vi.mocked(transactionNameHasWildcard).mockReturnValue(true); + // Mock wildcard detection: current name has wildcard, new name doesn't + vi.mocked(transactionNameHasWildcard).mockImplementation((name: string) => { + return name === '/users/*'; // Only the current name has wildcard + }); // Target: Resolves to specific parameterized route vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/:id', 'route']); @@ -521,6 +523,45 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route'); }); + it('should not downgrade from wildcard route to URL', async () => { + // Setup: Current span has route source with wildcard + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: '/users/*', + data: { 'sentry.source': 'route' }, + } as any); + + // Mock wildcard detection: current name has wildcard, new name doesn't + vi.mocked(transactionNameHasWildcard).mockImplementation((name: string) => { + return name === '/users/*'; // Only the current wildcard name returns true + }); + + // Target: After timeout, resolves to URL (lazy route didn't finish loading) + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/123', 'url']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + __sentry_navigation_name_set__: true, // Mark span as already named/finalized + } as unknown as Span; + + updateNavigationSpan( + testSpan, + { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/*', element:
}], + false, + vi.fn(() => [{ route: { path: '/users/*' } }]), + ); + + // Should not update - keep wildcard route instead of downgrading to URL + // Wildcard routes are better than URLs for aggregation in performance monitoring + expect(mockUpdateName).not.toHaveBeenCalled(); + expect(mockSetAttribute).not.toHaveBeenCalled(); + }); + it('should set name when no current name exists', async () => { // Setup: Current span has no name (undefined) vi.mocked(spanToJSON).mockReturnValue({ From c58396aaf5c7be117475fc00062a7f747e4cdc84 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 13 Nov 2025 13:44:26 +0000 Subject: [PATCH 06/19] Update packages/react/src/reactrouter-compat-utils/instrumentation.tsx Co-authored-by: Sigrid <32902192+s1gr1d@users.noreply.github.com> --- packages/react/src/reactrouter-compat-utils/instrumentation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index cb87d8394764..b122cd84d7ef 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -103,7 +103,7 @@ export interface ReactRouterOptions { * * Defaults to 3× the configured `idleTimeout`. Set to `0` to not wait at all. * - * Default: idleTimeout * 3 + * @default idleTimeout * 3 */ maxLazyRouteWaitMs?: number; } From 1a7df52f9810ee1781a8a2d23df694dcae67b20e Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 13 Nov 2025 13:49:51 +0000 Subject: [PATCH 07/19] Address review comments --- .../instrumentation.tsx | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index b122cd84d7ef..03049817e7ba 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -53,7 +53,7 @@ let _enableAsyncRouteHandlers: boolean = false; const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet(); // Maximum time (ms) to wait for lazy routes (configured in setup(), default: idleTimeout * 3) -let _maxLazyRouteWaitMs = 3000; +let _lazyRouteTimeout = 3000; /** * Adds resolved routes as children to the parent route. @@ -101,11 +101,11 @@ export interface ReactRouterOptions { /** * Maximum time (in milliseconds) to wait for lazy routes to load before finalizing span names. * - * Defaults to 3× the configured `idleTimeout`. Set to `0` to not wait at all. + * Set to `0` to not wait at all, or `Infinity` to wait indefinitely. * * @default idleTimeout * 3 */ - maxLazyRouteWaitMs?: number; + lazyRouteTimeout?: number; } type V6CompatibleVersion = '6' | '7'; @@ -501,7 +501,7 @@ export function createReactRouterV6CompatibleTracingIntegration( enableAsyncRouteHandlers = false, instrumentPageLoad = true, instrumentNavigation = true, - maxLazyRouteWaitMs, + lazyRouteTimeout, } = options; return { @@ -514,24 +514,24 @@ export function createReactRouterV6CompatibleTracingIntegration( // Note: options already contains idleTimeout if user passed it to browserTracingIntegration // Calculate default: 3× idleTimeout, allow explicit override const defaultMaxWait = (options.idleTimeout ?? 1000) * 3; - const configuredMaxWait = maxLazyRouteWaitMs ?? defaultMaxWait; + const configuredMaxWait = lazyRouteTimeout ?? defaultMaxWait; // Validate and set if (Number.isNaN(configuredMaxWait)) { DEBUG_BUILD && - debug.warn('[React Router] maxLazyRouteWaitMs must be a number, falling back to default:', defaultMaxWait); - _maxLazyRouteWaitMs = defaultMaxWait; + debug.warn('[React Router] lazyRouteTimeout must be a number, falling back to default:', defaultMaxWait); + _lazyRouteTimeout = defaultMaxWait; } else if (configuredMaxWait < 0 && configuredMaxWait !== Infinity) { DEBUG_BUILD && debug.warn( - '[React Router] maxLazyRouteWaitMs must be non-negative or Infinity, got:', + '[React Router] lazyRouteTimeout must be non-negative or Infinity, got:', configuredMaxWait, 'falling back to:', defaultMaxWait, ); - _maxLazyRouteWaitMs = defaultMaxWait; + _lazyRouteTimeout = defaultMaxWait; } else { - _maxLazyRouteWaitMs = configuredMaxWait; + _lazyRouteTimeout = configuredMaxWait; } _useEffect = useEffect; @@ -911,10 +911,13 @@ function patchSpanEnd( const originalEnd = span.end.bind(span); + // Prevent duplicate work when end() is called multiple times during the async lazy route wait. + // External code (visibility changes, timeouts) can call end() again, which would wastefully + // create promise chains and run expensive route matching before reaching the base span.end() guard. let endCalled = false; span.end = function patchedEnd(...args) { - // Prevent multiple calls to end() + // Guard against re-entry if (endCalled) { return; } @@ -928,7 +931,7 @@ function patchSpanEnd( const pendingPromises = pendingLazyRouteLoads.get(span); if (pendingPromises && pendingPromises.size > 0 && currentName && transactionNameHasWildcard(currentName)) { // Special case: 0 means don't wait at all (legacy behavior) - if (_maxLazyRouteWaitMs === 0) { + if (_lazyRouteTimeout === 0) { // Don't wait - immediately update and end span tryUpdateSpanNameBeforeEnd( span, @@ -943,17 +946,14 @@ function patchSpanEnd( return originalEnd(...args); } - // Take snapshot of current promises to wait for (prevents race conditions with new navigations) - const promiseArray = Array.from(pendingPromises); - - // Wait for all lazy routes to settle (never rejects, safe for all outcomes) - const allSettled = Promise.allSettled(promiseArray).then(() => {}); + // Wait for lazy routes that are currently loading + const allSettled = Promise.allSettled(pendingPromises).then(() => {}); // Race against timeout or wait indefinitely if Infinity const waitPromise = - _maxLazyRouteWaitMs === Infinity + _lazyRouteTimeout === Infinity ? allSettled - : Promise.race([allSettled, new Promise(r => setTimeout(r, _maxLazyRouteWaitMs))]); + : Promise.race([allSettled, new Promise(r => setTimeout(r, _lazyRouteTimeout))]); // Update span name once routes are resolved or timeout expires waitPromise From 7966e3f300aaa711519ae1d1992e3dfe5c9b503d Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 13 Nov 2025 16:24:15 +0000 Subject: [PATCH 08/19] Address copilot review --- .../instrumentation.tsx | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index 03049817e7ba..299d8e50151d 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -101,7 +101,11 @@ export interface ReactRouterOptions { /** * Maximum time (in milliseconds) to wait for lazy routes to load before finalizing span names. * - * Set to `0` to not wait at all, or `Infinity` to wait indefinitely. + * - Set to `0` to not wait at all (immediate finalization) + * - Set to `Infinity` to wait indefinitely + * - Negative values will fall back to the default + * + * Defaults to 3× the configured `idleTimeout` (default: 3000ms). * * @default idleTimeout * 3 */ @@ -204,11 +208,12 @@ export function updateNavigationSpan( // Check if this span has already been named to avoid multiple updates // But allow updates if: - // 1. This is a forced update (e.g., when lazy routes are loaded) - // 2. The current name has wildcards (incomplete parameterization) + // 1. Not yet finalized (!hasBeenNamed) + // 2. Forced update (e.g., when lazy routes are loaded) + // 3. Current name has wildcards (incomplete parameterization - safety valve) const hasBeenNamed = (activeRootSpan as { __sentry_navigation_name_set__?: boolean })?.__sentry_navigation_name_set__; const currentNameHasWildcard = currentName && transactionNameHasWildcard(currentName); - const shouldUpdate = forceUpdate || !hasBeenNamed || currentNameHasWildcard; + const shouldUpdate = !hasBeenNamed || forceUpdate || currentNameHasWildcard; if (shouldUpdate && !spanJson.timestamp) { // Get fresh branches for the current location with all loaded routes @@ -226,10 +231,10 @@ export function updateNavigationSpan( const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; const isImprovement = name && - (!hasBeenNamed || // Span not finalized - accept any name - !currentName || // No current name - always set - (currentNameHasWildcard && source === 'route') || // Wildcard route → better route (MUST stay in route source) - (currentSource !== 'route' && source === 'route')); // URL → route upgrade + (!currentName || // No current name - always set + (!hasBeenNamed && (currentSource !== 'route' || source === 'route')) || // Not finalized - allow unless downgrading route→url + (currentSource !== 'route' && source === 'route') || // URL → route upgrade + (currentSource === 'route' && source === 'route' && currentNameHasWildcard)); // Route → better route (only if current has wildcard) if (isImprovement) { activeRootSpan.updateName(name); activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); @@ -521,7 +526,7 @@ export function createReactRouterV6CompatibleTracingIntegration( DEBUG_BUILD && debug.warn('[React Router] lazyRouteTimeout must be a number, falling back to default:', defaultMaxWait); _lazyRouteTimeout = defaultMaxWait; - } else if (configuredMaxWait < 0 && configuredMaxWait !== Infinity) { + } else if (configuredMaxWait < 0) { DEBUG_BUILD && debug.warn( '[React Router] lazyRouteTimeout must be non-negative or Infinity, got:', @@ -871,12 +876,12 @@ function tryUpdateSpanNameBeforeEnd( // Only update if we have a valid name and it's better than current // Upgrade conditions: // 1. No current name exists - // 2. Current name has wildcards and new source is also 'route' (never downgrade route→url) + // 2. Current name has wildcards and new name is non-wildcard route (wildcard resolution) // 3. Upgrading from non-route source to route source (e.g., URL -> parameterized route) const isImprovement = name && (!currentName || // No current name - always set - (hasWildcard && source === 'route') || // Wildcard route → better route (MUST stay in route source) + (hasWildcard && source === 'route' && !transactionNameHasWildcard(name)) || // Wildcard → non-wildcard route (currentSource !== 'route' && source === 'route')); // URL → route upgrade const spanNotEnded = spanType === 'pageload' || !spanJson.timestamp; @@ -935,8 +940,8 @@ function patchSpanEnd( // Don't wait - immediately update and end span tryUpdateSpanNameBeforeEnd( span, - spanToJSON(span), - spanToJSON(span).description, + spanJson, + currentName, location, routes, basename, @@ -958,10 +963,12 @@ function patchSpanEnd( // Update span name once routes are resolved or timeout expires waitPromise .then(() => { + // Re-fetch span state after async wait (span may have been updated) + const updatedSpanJson = spanToJSON(span); tryUpdateSpanNameBeforeEnd( span, - spanToJSON(span), - spanToJSON(span).description, + updatedSpanJson, + updatedSpanJson.description, location, routes, basename, From 0a84ffd4037259346bb0c021d97537f3d887b96a Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Fri, 14 Nov 2025 10:16:24 +0000 Subject: [PATCH 09/19] Extract all update decision logic to `shouldUpdateWildcardSpanName` --- .../instrumentation.tsx | 84 +++++++++++++------ 1 file changed, 60 insertions(+), 24 deletions(-) diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index 299d8e50151d..e037fc13b4a3 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -753,8 +753,8 @@ export function handleNavigation(opts: { // If we're already in a navigation span, check if we should update its name if (isAlreadyInNavigationSpan && activeSpan) { - // Only update if the new name is better (doesn't have wildcards or is more complete) - const shouldUpdate = currentName && transactionNameHasWildcard(currentName) && !transactionNameHasWildcard(name); + const currentSource = spanJson?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; + const shouldUpdate = shouldUpdateWildcardSpanName(currentName, currentSource, name, source); if (shouldUpdate) { activeSpan.updateName(name); @@ -843,6 +843,59 @@ function updatePageloadTransaction({ } } +/** + * Determines if a span name should be updated during wildcard route resolution. + * + * This handles cases where: + * 1. A wildcard route name (e.g., "/users/*") should be resolved to a specific route + * 2. A URL-based name should be upgraded to a parameterized route name + * 3. No current name exists and a route name is available (pageload transactions) + * + * @param currentName - The current span name (may be undefined) + * @param currentSource - The current span source ('route', 'url', or undefined) + * @param newName - The proposed new span name + * @param newSource - The proposed new span source + * @param allowNoCurrentName - If true, allow updates when there's no current name (for pageload spans) + * @returns true if the span name should be updated + */ +function shouldUpdateWildcardSpanName( + currentName: string | undefined, + currentSource: string | undefined, + newName: string, + newSource: string, + allowNoCurrentName = false, +): boolean { + if (!newName) { + return false; + } + + // Allow update if no current name exists and allowNoCurrentName is true (pageloads) + if (!currentName && allowNoCurrentName) { + return true; + } + + // Check if current name has wildcard + const hasWildcard = currentName && transactionNameHasWildcard(currentName); + + // Current has wildcard, new is non-wildcard route + if (hasWildcard && newSource === 'route' && !transactionNameHasWildcard(newName)) { + return true; + } + + // Source upgrade - URL → route (but never route → URL) + if (currentSource !== 'route' && newSource === 'route') { + return true; + } + + // Allow route-to-route updates if names are different (legitimate navigation) + if (currentSource === 'route' && newSource === 'route' && currentName !== newName) { + return true; + } + + // Otherwise, don't update (prevents route→url downgrade) + return false; +} + /** Updates span name before end using latest route information. */ function tryUpdateSpanNameBeforeEnd( span: Span, @@ -856,10 +909,9 @@ function tryUpdateSpanNameBeforeEnd( ): void { try { const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; - const hasWildcard = currentName && transactionNameHasWildcard(currentName); // Only attempt update if source is not 'route' or if the name has wildcards - if (currentSource === 'route' && !hasWildcard) { + if (currentSource === 'route' && currentName && !transactionNameHasWildcard(currentName)) { return; } @@ -873,16 +925,9 @@ function tryUpdateSpanNameBeforeEnd( const [name, source] = resolveRouteNameAndSource(location, routesToUse, routesToUse, branches, basename); - // Only update if we have a valid name and it's better than current - // Upgrade conditions: - // 1. No current name exists - // 2. Current name has wildcards and new name is non-wildcard route (wildcard resolution) - // 3. Upgrading from non-route source to route source (e.g., URL -> parameterized route) - const isImprovement = - name && - (!currentName || // No current name - always set - (hasWildcard && source === 'route' && !transactionNameHasWildcard(name)) || // Wildcard → non-wildcard route - (currentSource !== 'route' && source === 'route')); // URL → route upgrade + // Check if the new name is an improvement over the current name + // For pageload spans, allow updates when there's no current name + const isImprovement = shouldUpdateWildcardSpanName(currentName, currentSource, name, source, true); const spanNotEnded = spanType === 'pageload' || !spanJson.timestamp; if (isImprovement && spanNotEnded) { @@ -938,16 +983,7 @@ function patchSpanEnd( // Special case: 0 means don't wait at all (legacy behavior) if (_lazyRouteTimeout === 0) { // Don't wait - immediately update and end span - tryUpdateSpanNameBeforeEnd( - span, - spanJson, - currentName, - location, - routes, - basename, - spanType, - allRoutes, - ); + tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutes); return originalEnd(...args); } From d700f3afbf0fba91035b9450c69bfd63b9326c4f Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Mon, 17 Nov 2025 08:53:46 +0000 Subject: [PATCH 10/19] Fix duplication --- .../instrumentation.tsx | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index e037fc13b4a3..bcf362d9bb19 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -48,6 +48,9 @@ let _useLocation: UseLocation; let _useNavigationType: UseNavigationType; let _createRoutesFromChildren: CreateRoutesFromChildren; let _matchRoutes: MatchRoutes; + +// Track the last created navigation span to prevent duplicates when router.subscribe fires multiple times +let _lastCreatedNavigationSpanName: string | null = null; let _enableAsyncRouteHandlers: boolean = false; const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet(); @@ -713,6 +716,7 @@ function wrapPatchRoutesOnNavigation( }; } +// eslint-disable-next-line complexity export function handleNavigation(opts: { location: Location; routes: RouteObject[]; @@ -753,8 +757,8 @@ export function handleNavigation(opts: { // If we're already in a navigation span, check if we should update its name if (isAlreadyInNavigationSpan && activeSpan) { - const currentSource = spanJson?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; - const shouldUpdate = shouldUpdateWildcardSpanName(currentName, currentSource, name, source); + // Only update if the new name is better (doesn't have wildcards or is more complete) + const shouldUpdate = currentName && transactionNameHasWildcard(currentName) && !transactionNameHasWildcard(name); if (shouldUpdate) { activeSpan.updateName(name); @@ -762,6 +766,12 @@ export function handleNavigation(opts: { DEBUG_BUILD && debug.log(`[Tracing] Updated navigation span name from "${currentName}" to "${name}"`); } } else if (!isAlreadyInNavigationSpan) { + // Prevent duplicate navigation spans when router.subscribe fires multiple times + // with the same route information after a span completes + if (_lastCreatedNavigationSpanName === name) { + return; + } + // Cross usage can result in multiple navigation spans being created without this check const navigationSpan = startBrowserTracingNavigationSpan(client, { name, @@ -772,6 +782,9 @@ export function handleNavigation(opts: { }, }); + // Track this navigation span to prevent immediate duplicates + _lastCreatedNavigationSpanName = name; + // Patch navigation span to handle early cancellation (e.g., document.hidden) if (navigationSpan) { patchNavigationSpanEnd(navigationSpan, location, routes, basename, allRoutes); @@ -887,11 +900,6 @@ function shouldUpdateWildcardSpanName( return true; } - // Allow route-to-route updates if names are different (legitimate navigation) - if (currentSource === 'route' && newSource === 'route' && currentName !== newName) { - return true; - } - // Otherwise, don't update (prevents route→url downgrade) return false; } From be8a6686ce7cedb8c208a212089cae973c398e6f Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Mon, 17 Nov 2025 12:25:31 +0000 Subject: [PATCH 11/19] Prevent stale navigation spans from blocking legitimate navigations --- .../instrumentation.tsx | 462 ++++++++---------- 1 file changed, 210 insertions(+), 252 deletions(-) diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index bcf362d9bb19..2b8827fb45c9 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -49,36 +49,22 @@ let _useNavigationType: UseNavigationType; let _createRoutesFromChildren: CreateRoutesFromChildren; let _matchRoutes: MatchRoutes; -// Track the last created navigation span to prevent duplicates when router.subscribe fires multiple times -let _lastCreatedNavigationSpanName: string | null = null; let _enableAsyncRouteHandlers: boolean = false; +let _lazyRouteTimeout = 3000; const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet(); -// Maximum time (ms) to wait for lazy routes (configured in setup(), default: idleTimeout * 3) -let _lazyRouteTimeout = 3000; - -/** - * Adds resolved routes as children to the parent route. - * Prevents duplicate routes by checking if they already exist. - */ -export function addResolvedRoutesToParent(resolvedRoutes: RouteObject[], parentRoute: RouteObject): void { - const existingChildren = parentRoute.children || []; +// Prevents duplicate spans when router.subscribe fires multiple times +const activeNavigationSpans = new WeakMap< + Client, + { span: Span; routeName: string; pathname: string; isPlaceholder?: boolean } +>(); - const newRoutes = resolvedRoutes.filter( - newRoute => - !existingChildren.some( - existing => - existing === newRoute || - (newRoute.path && existing.path === newRoute.path) || - (newRoute.id && existing.id === newRoute.id), - ), - ); +// Exported for testing only +export const allRoutes = new Set(); - if (newRoutes.length > 0) { - parentRoute.children = [...existingChildren, ...newRoutes]; - } -} +// Tracks lazy route loads to wait before finalizing span names +const pendingLazyRouteLoads = new WeakMap>>(); export interface ReactRouterOptions { useEffect: UseEffect; @@ -110,6 +96,11 @@ export interface ReactRouterOptions { * * Defaults to 3× the configured `idleTimeout` (default: 3000ms). * + * **Note**: This option only works with data routers (createBrowserRouter/createMemoryRouter) + * that use `patchRoutesOnNavigation`. Component-based routes (/useRoutes) that use + * React.lazy() for code splitting cannot track lazy loads at the router level, so spans + * will finalize immediately regardless of this setting. + * * @default idleTimeout * 3 */ lazyRouteTimeout?: number; @@ -117,12 +108,27 @@ export interface ReactRouterOptions { type V6CompatibleVersion = '6' | '7'; -// Keeping as a global variable for cross-usage in multiple functions -// only exported for testing purposes -export const allRoutes = new Set(); +/** + * Adds resolved routes as children to the parent route. + * Prevents duplicate routes by checking if they already exist. + */ +export function addResolvedRoutesToParent(resolvedRoutes: RouteObject[], parentRoute: RouteObject): void { + const existingChildren = parentRoute.children || []; -/** Tracks pending lazy route loads per span to wait before finalizing span names. */ -const pendingLazyRouteLoads = new WeakMap>>(); + const newRoutes = resolvedRoutes.filter( + newRoute => + !existingChildren.some( + existing => + existing === newRoute || + (newRoute.path && existing.path === newRoute.path) || + (newRoute.id && existing.id === newRoute.id), + ), + ); + + if (newRoutes.length > 0) { + parentRoute.children = [...existingChildren, ...newRoutes]; + } +} /** Registers a pending lazy route load promise for a span. */ function trackLazyRouteLoad(span: Span, promise: Promise): void { @@ -209,17 +215,11 @@ export function updateNavigationSpan( const spanJson = spanToJSON(activeRootSpan); const currentName = spanJson.description; - // Check if this span has already been named to avoid multiple updates - // But allow updates if: - // 1. Not yet finalized (!hasBeenNamed) - // 2. Forced update (e.g., when lazy routes are loaded) - // 3. Current name has wildcards (incomplete parameterization - safety valve) const hasBeenNamed = (activeRootSpan as { __sentry_navigation_name_set__?: boolean })?.__sentry_navigation_name_set__; const currentNameHasWildcard = currentName && transactionNameHasWildcard(currentName); const shouldUpdate = !hasBeenNamed || forceUpdate || currentNameHasWildcard; if (shouldUpdate && !spanJson.timestamp) { - // Get fresh branches for the current location with all loaded routes const currentBranches = matchRoutes(allRoutes, location); const [name, source] = resolveRouteNameAndSource( location, @@ -229,8 +229,6 @@ export function updateNavigationSpan( '', ); - // Only update if we have a valid name and it's better than what we have - // Never downgrade from route source to url source const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; const isImprovement = name && @@ -242,8 +240,8 @@ export function updateNavigationSpan( activeRootSpan.updateName(name); activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); - // Only mark as finalized if the new name doesn't have wildcards - if (!transactionNameHasWildcard(name)) { + // Only mark as finalized for non-wildcard route names (allows URL→route upgrades). + if (!transactionNameHasWildcard(name) && source === 'route') { addNonEnumerableProperty( activeRootSpan as { __sentry_navigation_name_set__?: boolean }, '__sentry_navigation_name_set__', @@ -254,6 +252,81 @@ export function updateNavigationSpan( } } +function setupRouterSubscription( + router: Router, + routes: RouteObject[], + version: V6CompatibleVersion, + basename: string | undefined, + activeRootSpan: Span | undefined, +): void { + let isInitialPageloadComplete = false; + let hasSeenPageloadSpan = !!activeRootSpan && spanToJSON(activeRootSpan).op === 'pageload'; + let hasSeenPopAfterPageload = false; + let scheduledNavigationHandler: number | null = null; + let lastHandledPathname: string | null = null; + + router.subscribe((state: RouterState) => { + if (!isInitialPageloadComplete) { + const currentRootSpan = getActiveRootSpan(); + const isCurrentlyInPageload = currentRootSpan && spanToJSON(currentRootSpan).op === 'pageload'; + + if (isCurrentlyInPageload) { + hasSeenPageloadSpan = true; + } else if (hasSeenPageloadSpan) { + if (state.historyAction === 'POP' && !hasSeenPopAfterPageload) { + hasSeenPopAfterPageload = true; + } else { + isInitialPageloadComplete = true; + } + } + } + + const shouldHandleNavigation = + state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete); + + if (shouldHandleNavigation) { + // Include search and hash to allow query/hash-only navigations + const currentLocationKey = `${state.location.pathname}${state.location.search}${state.location.hash}`; + const navigationHandler = (): void => { + // Prevent multiple calls for the same location within the same navigation cycle + if (lastHandledPathname === currentLocationKey) { + return; + } + lastHandledPathname = currentLocationKey; + scheduledNavigationHandler = null; + handleNavigation({ + location: state.location, + routes, + navigationType: state.historyAction, + version, + basename, + allRoutes: Array.from(allRoutes), + }); + }; + + if (state.navigation.state !== 'idle') { + // Navigation in progress - reset if location changed + if (lastHandledPathname !== currentLocationKey) { + lastHandledPathname = null; + } + // Cancel any previously scheduled handler to avoid duplicates + if (scheduledNavigationHandler !== null) { + cancelAnimationFrame(scheduledNavigationHandler); + } + scheduledNavigationHandler = requestAnimationFrame(navigationHandler); + } else { + // Navigation completed - cancel scheduled handler if any, then call immediately + if (scheduledNavigationHandler !== null) { + cancelAnimationFrame(scheduledNavigationHandler); + scheduledNavigationHandler = null; + } + navigationHandler(); + // Don't reset - next navigation cycle resets to prevent duplicates within same cycle. + } + } + }); +} + /** * Creates a wrapCreateBrowserRouter function that can be used with all React Router v6 compatible versions. */ @@ -276,30 +349,17 @@ export function createV6CompatibleWrapCreateBrowserRouter< return function (routes: RouteObject[], opts?: Record & { basename?: string }): TRouter { addRoutesToAllRoutes(routes); - // Check for async handlers that might contain sub-route declarations (only if enabled) if (_enableAsyncRouteHandlers) { for (const route of routes) { checkRouteForAsyncHandler(route, processResolvedRoutes); } } - // Wrap patchRoutesOnNavigation to detect when lazy routes are loaded const wrappedOpts = wrapPatchRoutesOnNavigation(opts); - const router = createRouterFunction(routes, wrappedOpts); const basename = opts?.basename; - const activeRootSpan = getActiveRootSpan(); - // Track whether we've completed the initial pageload to properly distinguish - // between POPs that occur during pageload vs. legitimate back/forward navigation. - let isInitialPageloadComplete = false; - let hasSeenPageloadSpan = !!activeRootSpan && spanToJSON(activeRootSpan).op === 'pageload'; - let hasSeenPopAfterPageload = false; - - // The initial load ends when `createBrowserRouter` is called. - // This is the earliest convenient time to update the transaction name. - // Callbacks to `router.subscribe` are not called for the initial load. if (router.state.historyAction === 'POP' && activeRootSpan) { updatePageloadTransaction({ activeRootSpan, @@ -310,50 +370,7 @@ export function createV6CompatibleWrapCreateBrowserRouter< }); } - router.subscribe((state: RouterState) => { - // Track pageload completion to distinguish POPs during pageload from legitimate back/forward navigation - if (!isInitialPageloadComplete) { - const currentRootSpan = getActiveRootSpan(); - const isCurrentlyInPageload = currentRootSpan && spanToJSON(currentRootSpan).op === 'pageload'; - - if (isCurrentlyInPageload) { - hasSeenPageloadSpan = true; - } else if (hasSeenPageloadSpan) { - // Pageload span was active but is now gone - pageload has completed - if (state.historyAction === 'POP' && !hasSeenPopAfterPageload) { - // Pageload ended: ignore the first POP after pageload - hasSeenPopAfterPageload = true; - } else { - // Pageload ended: either non-POP action or subsequent POP - isInitialPageloadComplete = true; - } - } - // If we haven't seen a pageload span yet, keep waiting (don't mark as complete) - } - - const shouldHandleNavigation = - state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete); - - if (shouldHandleNavigation) { - const navigationHandler = (): void => { - handleNavigation({ - location: state.location, - routes, - navigationType: state.historyAction, - version, - basename, - allRoutes: Array.from(allRoutes), - }); - }; - - // Wait for the next render if loading an unsettled route - if (state.navigation.state !== 'idle') { - requestAnimationFrame(navigationHandler); - } else { - navigationHandler(); - } - } - }); + setupRouterSubscription(router, routes, version, basename, activeRootSpan); return router; }; @@ -388,14 +405,12 @@ export function createV6CompatibleWrapCreateMemoryRouter< ): TRouter { addRoutesToAllRoutes(routes); - // Check for async handlers that might contain sub-route declarations (only if enabled) if (_enableAsyncRouteHandlers) { for (const route of routes) { checkRouteForAsyncHandler(route, processResolvedRoutes); } } - // Wrap patchRoutesOnNavigation to detect when lazy routes are loaded const wrappedOpts = wrapPatchRoutesOnNavigation(opts, true); const router = createRouterFunction(routes, wrappedOpts); @@ -433,58 +448,7 @@ export function createV6CompatibleWrapCreateMemoryRouter< }); } - // Track whether we've completed the initial pageload to properly distinguish - // between POPs that occur during pageload vs. legitimate back/forward navigation. - let isInitialPageloadComplete = false; - let hasSeenPageloadSpan = !!memoryActiveRootSpan && spanToJSON(memoryActiveRootSpan).op === 'pageload'; - let hasSeenPopAfterPageload = false; - - router.subscribe((state: RouterState) => { - // Track pageload completion to distinguish POPs during pageload from legitimate back/forward navigation - if (!isInitialPageloadComplete) { - const currentRootSpan = getActiveRootSpan(); - const isCurrentlyInPageload = currentRootSpan && spanToJSON(currentRootSpan).op === 'pageload'; - - if (isCurrentlyInPageload) { - hasSeenPageloadSpan = true; - } else if (hasSeenPageloadSpan) { - // Pageload span was active but is now gone - pageload has completed - if (state.historyAction === 'POP' && !hasSeenPopAfterPageload) { - // Pageload ended: ignore the first POP after pageload - hasSeenPopAfterPageload = true; - } else { - // Pageload ended: either non-POP action or subsequent POP - isInitialPageloadComplete = true; - } - } - // If we haven't seen a pageload span yet, keep waiting (don't mark as complete) - } - - const location = state.location; - - const shouldHandleNavigation = - state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete); - - if (shouldHandleNavigation) { - const navigationHandler = (): void => { - handleNavigation({ - location, - routes, - navigationType: state.historyAction, - version, - basename, - allRoutes: Array.from(allRoutes), - }); - }; - - // Wait for the next render if loading an unsettled route - if (state.navigation.state !== 'idle') { - requestAnimationFrame(navigationHandler); - } else { - navigationHandler(); - } - } - }); + setupRouterSubscription(router, routes, version, basename, memoryActiveRootSpan); return router; }; @@ -616,6 +580,9 @@ export function createV6CompatibleWrapUseRoutes(origUseRoutes: UseRoutes, versio }); isMountRenderPass.current = false; } else { + // Note: Component-based routes don't support lazy route tracking via lazyRouteTimeout + // because React.lazy() loads happen at the component level, not the router level. + // Use createBrowserRouter with patchRoutesOnNavigation for lazy route tracking. handleNavigation({ location: normalizedLocation, routes, @@ -652,7 +619,6 @@ function wrapPatchRoutesOnNavigation( const activeRootSpan = getActiveRootSpan(); - // For browser router, wrap the patch function to update span during patching if (!isMemoryRouter) { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access const originalPatch = (args as any)?.patch; @@ -666,7 +632,7 @@ function wrapPatchRoutesOnNavigation( currentActiveRootSpan, { pathname: targetPath, search: '', hash: '', state: null, key: 'default' }, Array.from(allRoutes), - true, // forceUpdate = true since we're loading lazy routes + true, _matchRoutes, ); } @@ -675,29 +641,19 @@ function wrapPatchRoutesOnNavigation( } } - // Create and track promise for this lazy load const lazyLoadPromise = (async () => { const result = await originalPatchRoutes(args); - // Update navigation span after routes are patched const currentActiveRootSpan = getActiveRootSpan(); if (currentActiveRootSpan && (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation') { - // Determine pathname based on router type - let pathname: string | undefined; - if (isMemoryRouter) { - // For memory routers, only use targetPath - pathname = targetPath; - } else { - // For browser routers, use targetPath or fall back to window.location - pathname = targetPath || WINDOW.location?.pathname; - } + const pathname = isMemoryRouter ? targetPath : targetPath || WINDOW.location?.pathname; if (pathname) { updateNavigationSpan( currentActiveRootSpan, { pathname, search: '', hash: '', state: null, key: 'default' }, Array.from(allRoutes), - false, // forceUpdate = false since this is after lazy routes are loaded + false, _matchRoutes, ); } @@ -706,7 +662,6 @@ function wrapPatchRoutesOnNavigation( return result; })(); - // Track the promise if we have an active span if (activeRootSpan) { trackLazyRouteLoad(activeRootSpan, lazyLoadPromise); } @@ -750,45 +705,72 @@ export function handleNavigation(opts: { basename, ); - const activeSpan = getActiveSpan(); - const spanJson = activeSpan && spanToJSON(activeSpan); - const isAlreadyInNavigationSpan = spanJson?.op === 'navigation'; - const currentName = spanJson?.description; + const trackedNav = activeNavigationSpans.get(client); - // If we're already in a navigation span, check if we should update its name - if (isAlreadyInNavigationSpan && activeSpan) { - // Only update if the new name is better (doesn't have wildcards or is more complete) - const shouldUpdate = currentName && transactionNameHasWildcard(currentName) && !transactionNameHasWildcard(name); - - if (shouldUpdate) { - activeSpan.updateName(name); - activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source as 'route' | 'url' | 'custom'); - DEBUG_BUILD && debug.log(`[Tracing] Updated navigation span name from "${currentName}" to "${name}"`); - } - } else if (!isAlreadyInNavigationSpan) { - // Prevent duplicate navigation spans when router.subscribe fires multiple times - // with the same route information after a span completes - if (_lastCreatedNavigationSpanName === name) { + // If we have a tracked navigation for this client, check if it's a duplicate + if (trackedNav) { + // If it's a placeholder for the same pathname, skip immediately (span creation in progress) + if (trackedNav.isPlaceholder && trackedNav.pathname === location.pathname) { + DEBUG_BUILD && + debug.log(`[Tracing] Skipping duplicate navigation - placeholder exists for pathname: ${location.pathname}`); return; } - // Cross usage can result in multiple navigation spans being created without this check - const navigationSpan = startBrowserTracingNavigationSpan(client, { - name, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`, - }, - }); + // For real spans (not placeholders), check if duplicate by pathname and end status + if (!trackedNav.isPlaceholder) { + const trackedSpanJson = spanToJSON(trackedNav.span); + const trackedSpanHasEnded = !!trackedSpanJson.timestamp; + + // If tracked span is for the same pathname and hasn't ended yet, this is a duplicate + if (trackedNav.pathname === location.pathname && !trackedSpanHasEnded) { + // Check if we should update from wildcard to parameterized + const shouldUpdate = + trackedNav.routeName && + transactionNameHasWildcard(trackedNav.routeName) && + !transactionNameHasWildcard(name); + + if (shouldUpdate) { + trackedNav.span.updateName(name); + trackedNav.span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source as 'route' | 'url' | 'custom'); + // Update the tracked route name so future checks use the new name + trackedNav.routeName = name; + DEBUG_BUILD && + debug.log(`[Tracing] Updated navigation span name from "${trackedNav.routeName}" to "${name}"`); + } + return; // Skip - duplicate (same pathname, span still active) + } + // If pathname is different or span has ended, allow creating new span + // This handles legitimate re-navigations like /users/123 -> /users/456 + } + } + + // Create new navigation span (first navigation or legitimate new navigation) + // Reserve the spot in the map first to prevent race conditions + // Mark as placeholder to prevent concurrent handleNavigation calls from creating duplicates + const placeholderSpan = { end: () => {} } as unknown as Span; + activeNavigationSpans.set(client, { + span: placeholderSpan, + routeName: name, + pathname: location.pathname, + isPlaceholder: true, + }); - // Track this navigation span to prevent immediate duplicates - _lastCreatedNavigationSpanName = name; + const navigationSpan = startBrowserTracingNavigationSpan(client, { + name, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`, + }, + }); - // Patch navigation span to handle early cancellation (e.g., document.hidden) - if (navigationSpan) { - patchNavigationSpanEnd(navigationSpan, location, routes, basename, allRoutes); - } + if (navigationSpan) { + // Update the map with the real span (isPlaceholder omitted, defaults to false) + activeNavigationSpans.set(client, { span: navigationSpan, routeName: name, pathname: location.pathname }); + patchSpanEnd(navigationSpan, location, routes, basename, allRoutes, 'navigation'); + } else { + // If no span was created, remove the placeholder + activeNavigationSpans.delete(client); } } } @@ -851,7 +833,7 @@ function updatePageloadTransaction({ activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); // Patch span.end() to ensure we update the name one last time before the span is sent - patchPageloadSpanEnd(activeRootSpan, location, routes, basename, allRoutes); + patchSpanEnd(activeRootSpan, location, routes, basename, allRoutes, 'pageload'); } } } @@ -859,10 +841,10 @@ function updatePageloadTransaction({ /** * Determines if a span name should be updated during wildcard route resolution. * - * This handles cases where: - * 1. A wildcard route name (e.g., "/users/*") should be resolved to a specific route - * 2. A URL-based name should be upgraded to a parameterized route name - * 3. No current name exists and a route name is available (pageload transactions) + * Update conditions (in priority order): + * 1. No current name + allowNoCurrentName: true → always update (pageload spans) + * 2. Current name has wildcard + new is route without wildcard → upgrade (e.g., "/users/*" → "/users/:id") + * 3. Current source is not 'route' + new source is 'route' → upgrade (e.g., URL → parameterized route) * * @param currentName - The current span name (may be undefined) * @param currentSource - The current span source ('route', 'url', or undefined) @@ -882,29 +864,23 @@ function shouldUpdateWildcardSpanName( return false; } - // Allow update if no current name exists and allowNoCurrentName is true (pageloads) if (!currentName && allowNoCurrentName) { return true; } - // Check if current name has wildcard const hasWildcard = currentName && transactionNameHasWildcard(currentName); - // Current has wildcard, new is non-wildcard route if (hasWildcard && newSource === 'route' && !transactionNameHasWildcard(newName)) { return true; } - // Source upgrade - URL → route (but never route → URL) if (currentSource !== 'route' && newSource === 'route') { return true; } - // Otherwise, don't update (prevents route→url downgrade) return false; } -/** Updates span name before end using latest route information. */ function tryUpdateSpanNameBeforeEnd( span: Span, spanJson: ReturnType, @@ -918,7 +894,6 @@ function tryUpdateSpanNameBeforeEnd( try { const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; - // Only attempt update if source is not 'route' or if the name has wildcards if (currentSource === 'route' && currentName && !transactionNameHasWildcard(currentName)) { return; } @@ -933,8 +908,6 @@ function tryUpdateSpanNameBeforeEnd( const [name, source] = resolveRouteNameAndSource(location, routesToUse, routesToUse, branches, basename); - // Check if the new name is an improvement over the current name - // For pageload spans, allow updates when there's no current name const isImprovement = shouldUpdateWildcardSpanName(currentName, currentSource, name, source, true); const spanNotEnded = spanType === 'pageload' || !spanJson.timestamp; @@ -943,7 +916,6 @@ function tryUpdateSpanNameBeforeEnd( span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); } } catch (error) { - // Silently catch errors to ensure span.end() is always called DEBUG_BUILD && debug.warn(`Error updating span details before ending: ${error}`); } } @@ -968,14 +940,9 @@ function patchSpanEnd( } const originalEnd = span.end.bind(span); - - // Prevent duplicate work when end() is called multiple times during the async lazy route wait. - // External code (visibility changes, timeouts) can call end() again, which would wastefully - // create promise chains and run expensive route matching before reaching the base span.end() guard. let endCalled = false; span.end = function patchedEnd(...args) { - // Guard against re-entry if (endCalled) { return; } @@ -983,31 +950,42 @@ function patchSpanEnd( const spanJson = spanToJSON(span); const currentName = spanJson.description; + const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; + + // Clean up activeNavigationSpans entry for this span to prevent stale entries + const client = getClient(); + if (client && spanType === 'navigation') { + const trackedNav = activeNavigationSpans.get(client); + if (trackedNav && trackedNav.span === span) { + activeNavigationSpans.delete(client); + } + } - // If we have pending lazy route loads and the current name has wildcards, - // wait for promises to resolve (with timeout) before finalizing span const pendingPromises = pendingLazyRouteLoads.get(span); - if (pendingPromises && pendingPromises.size > 0 && currentName && transactionNameHasWildcard(currentName)) { - // Special case: 0 means don't wait at all (legacy behavior) + // Wait for lazy routes if: + // 1. There are pending promises AND + // 2. Current name exists AND + // 3. Either the name has a wildcard OR the source is not 'route' (URL-based names) + const shouldWaitForLazyRoutes = + pendingPromises && + pendingPromises.size > 0 && + currentName && + (transactionNameHasWildcard(currentName) || currentSource !== 'route'); + + if (shouldWaitForLazyRoutes) { if (_lazyRouteTimeout === 0) { - // Don't wait - immediately update and end span tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutes); return originalEnd(...args); } - // Wait for lazy routes that are currently loading const allSettled = Promise.allSettled(pendingPromises).then(() => {}); - - // Race against timeout or wait indefinitely if Infinity const waitPromise = _lazyRouteTimeout === Infinity ? allSettled : Promise.race([allSettled, new Promise(r => setTimeout(r, _lazyRouteTimeout))]); - // Update span name once routes are resolved or timeout expires waitPromise .then(() => { - // Re-fetch span state after async wait (span may have been updated) const updatedSpanJson = spanToJSON(span); tryUpdateSpanNameBeforeEnd( span, @@ -1022,7 +1000,6 @@ function patchSpanEnd( originalEnd(...args); }) .catch(() => { - // Defensive: should never happen with allSettled, but satisfy ESLint originalEnd(...args); }); return; @@ -1032,30 +1009,9 @@ function patchSpanEnd( return originalEnd(...args); }; - // Mark this span as having its end() method patched to prevent duplicate patching addNonEnumerableProperty(span as unknown as Record, patchedPropertyName, true); } -function patchPageloadSpanEnd( - span: Span, - location: Location, - routes: RouteObject[], - basename: string | undefined, - _allRoutes: RouteObject[] | undefined, -): void { - patchSpanEnd(span, location, routes, basename, _allRoutes, 'pageload'); -} - -function patchNavigationSpanEnd( - span: Span, - location: Location, - routes: RouteObject[], - basename: string | undefined, - _allRoutes: RouteObject[] | undefined, -): void { - patchSpanEnd(span, location, routes, basename, _allRoutes, 'navigation'); -} - // eslint-disable-next-line @typescript-eslint/no-explicit-any export function createV6CompatibleWithSentryReactRouterRouting

, R extends React.FC

>( Routes: R, @@ -1091,11 +1047,13 @@ export function createV6CompatibleWithSentryReactRouterRouting

Date: Mon, 17 Nov 2025 12:51:45 +0000 Subject: [PATCH 12/19] Fix the debug log. --- .../react/src/reactrouter-compat-utils/instrumentation.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index 2b8827fb45c9..3475803aeb3e 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -730,12 +730,12 @@ export function handleNavigation(opts: { !transactionNameHasWildcard(name); if (shouldUpdate) { + const oldName = trackedNav.routeName; trackedNav.span.updateName(name); trackedNav.span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source as 'route' | 'url' | 'custom'); // Update the tracked route name so future checks use the new name trackedNav.routeName = name; - DEBUG_BUILD && - debug.log(`[Tracing] Updated navigation span name from "${trackedNav.routeName}" to "${name}"`); + DEBUG_BUILD && debug.log(`[Tracing] Updated navigation span name from "${oldName}" to "${name}"`); } return; // Skip - duplicate (same pathname, span still active) } From 5c0d2f0f812199b9c75912dabbf9707cc9b16564 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Mon, 17 Nov 2025 13:25:27 +0000 Subject: [PATCH 13/19] Address copilot review --- packages/react/src/reactrouter-compat-utils/utils.ts | 4 ++-- .../test/reactrouter-compat-utils/instrumentation.test.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react/src/reactrouter-compat-utils/utils.ts b/packages/react/src/reactrouter-compat-utils/utils.ts index 9de0cfe88d38..98b18b7d22c4 100644 --- a/packages/react/src/reactrouter-compat-utils/utils.ts +++ b/packages/react/src/reactrouter-compat-utils/utils.ts @@ -38,9 +38,9 @@ export function pathEndsWithWildcard(path: string): boolean { return path.endsWith('*'); } -/** Checks if transaction name has wildcard (/* or * or ends with *). */ +/** Checks if transaction name has wildcard (/* or ends with *). */ export function transactionNameHasWildcard(name: string): boolean { - return name.includes('/*') || name === '*' || name.endsWith('*'); + return name.includes('/*') || name.endsWith('*'); } /** diff --git a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx index c1626637daee..4346787a111b 100644 --- a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx +++ b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx @@ -480,7 +480,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { ); // Should not update because span is already named - // The early return in tryUpdateSpanNameBeforeEnd (line 815) protects against downgrades + // The early return in tryUpdateSpanNameBeforeEnd protects against downgrades // This test verifies that route->url downgrades are blocked expect(mockUpdateName).not.toHaveBeenCalled(); expect(mockSetAttribute).not.toHaveBeenCalled(); From 66164ac9e264ead330deb5d494cecb7357395f95 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Mon, 17 Nov 2025 14:19:42 +0000 Subject: [PATCH 14/19] Utilize passed allRoutes in patched spanEnd --- .../src/reactrouter-compat-utils/instrumentation.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index 3475803aeb3e..4409e6d54eda 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -939,6 +939,9 @@ function patchSpanEnd( return; } + // Use the passed route context, or fall back to global Set + const allRoutesSet = _allRoutes ? new Set(_allRoutes) : allRoutes; + const originalEnd = span.end.bind(span); let endCalled = false; @@ -974,7 +977,7 @@ function patchSpanEnd( if (shouldWaitForLazyRoutes) { if (_lazyRouteTimeout === 0) { - tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutes); + tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutesSet); return originalEnd(...args); } @@ -995,7 +998,7 @@ function patchSpanEnd( routes, basename, spanType, - allRoutes, + allRoutesSet, ); originalEnd(...args); }) @@ -1005,7 +1008,7 @@ function patchSpanEnd( return; } - tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutes); + tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutesSet); return originalEnd(...args); }; From d6690653b7e6328371c22a03b01230b711045f72 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Mon, 17 Nov 2025 16:35:26 +0000 Subject: [PATCH 15/19] Reduce complexity, cleanup --- .../instrumentation.tsx | 184 +++-- .../instrumentation.test.tsx | 685 +++++++++++++++++- 2 files changed, 818 insertions(+), 51 deletions(-) diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index 4409e6d54eda..d4ebc93ab7e9 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -57,7 +57,7 @@ const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet(); // Prevents duplicate spans when router.subscribe fires multiple times const activeNavigationSpans = new WeakMap< Client, - { span: Span; routeName: string; pathname: string; isPlaceholder?: boolean } + { span: Span; routeName: string; pathname: string; locationKey: string; isPlaceholder?: boolean } >(); // Exported for testing only @@ -66,6 +66,94 @@ export const allRoutes = new Set(); // Tracks lazy route loads to wait before finalizing span names const pendingLazyRouteLoads = new WeakMap>>(); +/** + * Schedules a callback using requestAnimationFrame when available (browser), + * or falls back to setTimeout for SSR environments (Node.js, createMemoryRouter tests). + */ +function scheduleCallback(callback: () => void): number { + if (WINDOW?.requestAnimationFrame) { + return WINDOW.requestAnimationFrame(callback); + } + return setTimeout(callback, 0) as unknown as number; +} + +/** + * Cancels a scheduled callback, handling both RAF (browser) and timeout (SSR) IDs. + */ +function cancelScheduledCallback(id: number): void { + if (WINDOW?.cancelAnimationFrame) { + WINDOW.cancelAnimationFrame(id); + } else { + clearTimeout(id); + } +} + +/** + * Computes a location key that uniquely identifies a navigation including pathname, search, and hash. + * + * Normalizes undefined/null search and hash to empty strings to ensure consistency between + * partial location objects (from ) and full location objects + * (from history state). This prevents duplicate navigation spans when using the location + * prop with string values (common in modal routes and SSR). + * + * Exported for testing. + * + * @example + * // Partial location (from ) + * computeLocationKey({ pathname: '/users', search: undefined, hash: undefined }) + * // Returns: '/users' + * + * // Full location (from history) + * computeLocationKey({ pathname: '/users', search: '', hash: '' }) + * // Returns: '/users' (same key - duplicate detection works correctly) + */ +export function computeLocationKey(location: Location): string { + return `${location.pathname}${location.search || ''}${location.hash || ''}`; +} + +/** + * Determines if a navigation should be skipped as a duplicate, and if an existing span should be updated. + * Exported for testing. + * + * @returns An object with: + * - skip: boolean - Whether to skip creating a new span + * - shouldUpdate: boolean - Whether to update the existing span name (wildcard upgrade) + */ +export function shouldSkipNavigation( + trackedNav: + | { span: Span; routeName: string; pathname: string; locationKey: string; isPlaceholder?: boolean } + | undefined, + locationKey: string, + proposedName: string, + spanHasEnded: boolean, +): { skip: boolean; shouldUpdate: boolean } { + if (!trackedNav) { + return { skip: false, shouldUpdate: false }; + } + + // If it's a placeholder for the same location, skip immediately (span creation in progress) + if (trackedNav.isPlaceholder && trackedNav.locationKey === locationKey) { + return { skip: true, shouldUpdate: false }; + } + + // For real spans (not placeholders), check if duplicate by location and end status + if (!trackedNav.isPlaceholder) { + // If tracked span is for the same location and hasn't ended yet, this is a duplicate + if (trackedNav.locationKey === locationKey && !spanHasEnded) { + // Check if we should update from wildcard to parameterized + const shouldUpdate = + !!trackedNav.routeName && + transactionNameHasWildcard(trackedNav.routeName) && + !transactionNameHasWildcard(proposedName); + + return { skip: true, shouldUpdate }; + } + } + + // Location is different or span has ended - allow creating new span + return { skip: false, shouldUpdate: false }; +} + export interface ReactRouterOptions { useEffect: UseEffect; useLocation: UseLocation; @@ -286,7 +374,8 @@ function setupRouterSubscription( if (shouldHandleNavigation) { // Include search and hash to allow query/hash-only navigations - const currentLocationKey = `${state.location.pathname}${state.location.search}${state.location.hash}`; + // Use computeLocationKey() to ensure undefined/null values are normalized to empty strings + const currentLocationKey = computeLocationKey(state.location); const navigationHandler = (): void => { // Prevent multiple calls for the same location within the same navigation cycle if (lastHandledPathname === currentLocationKey) { @@ -311,13 +400,13 @@ function setupRouterSubscription( } // Cancel any previously scheduled handler to avoid duplicates if (scheduledNavigationHandler !== null) { - cancelAnimationFrame(scheduledNavigationHandler); + cancelScheduledCallback(scheduledNavigationHandler); } - scheduledNavigationHandler = requestAnimationFrame(navigationHandler); + scheduledNavigationHandler = scheduleCallback(navigationHandler); } else { // Navigation completed - cancel scheduled handler if any, then call immediately if (scheduledNavigationHandler !== null) { - cancelAnimationFrame(scheduledNavigationHandler); + cancelScheduledCallback(scheduledNavigationHandler); scheduledNavigationHandler = null; } navigationHandler(); @@ -671,7 +760,6 @@ function wrapPatchRoutesOnNavigation( }; } -// eslint-disable-next-line complexity export function handleNavigation(opts: { location: Location; routes: RouteObject[]; @@ -705,43 +793,26 @@ export function handleNavigation(opts: { basename, ); + const locationKey = computeLocationKey(location); const trackedNav = activeNavigationSpans.get(client); - // If we have a tracked navigation for this client, check if it's a duplicate - if (trackedNav) { - // If it's a placeholder for the same pathname, skip immediately (span creation in progress) - if (trackedNav.isPlaceholder && trackedNav.pathname === location.pathname) { - DEBUG_BUILD && - debug.log(`[Tracing] Skipping duplicate navigation - placeholder exists for pathname: ${location.pathname}`); - return; - } - - // For real spans (not placeholders), check if duplicate by pathname and end status - if (!trackedNav.isPlaceholder) { - const trackedSpanJson = spanToJSON(trackedNav.span); - const trackedSpanHasEnded = !!trackedSpanJson.timestamp; - - // If tracked span is for the same pathname and hasn't ended yet, this is a duplicate - if (trackedNav.pathname === location.pathname && !trackedSpanHasEnded) { - // Check if we should update from wildcard to parameterized - const shouldUpdate = - trackedNav.routeName && - transactionNameHasWildcard(trackedNav.routeName) && - !transactionNameHasWildcard(name); - - if (shouldUpdate) { - const oldName = trackedNav.routeName; - trackedNav.span.updateName(name); - trackedNav.span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source as 'route' | 'url' | 'custom'); - // Update the tracked route name so future checks use the new name - trackedNav.routeName = name; - DEBUG_BUILD && debug.log(`[Tracing] Updated navigation span name from "${oldName}" to "${name}"`); - } - return; // Skip - duplicate (same pathname, span still active) - } - // If pathname is different or span has ended, allow creating new span - // This handles legitimate re-navigations like /users/123 -> /users/456 + // Determine if this navigation should be skipped as a duplicate + const trackedSpanHasEnded = + trackedNav && !trackedNav.isPlaceholder ? !!spanToJSON(trackedNav.span).timestamp : false; + const { skip, shouldUpdate } = shouldSkipNavigation(trackedNav, locationKey, name, trackedSpanHasEnded); + + if (skip) { + if (shouldUpdate && trackedNav) { + // Update existing span from wildcard to parameterized route name + const oldName = trackedNav.routeName; + trackedNav.span.updateName(name); + trackedNav.span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source as 'route' | 'url' | 'custom'); + trackedNav.routeName = name; + DEBUG_BUILD && debug.log(`[Tracing] Updated navigation span name from "${oldName}" to "${name}"`); + } else { + DEBUG_BUILD && debug.log(`[Tracing] Skipping duplicate navigation for location: ${locationKey}`); } + return; } // Create new navigation span (first navigation or legitimate new navigation) @@ -752,6 +823,7 @@ export function handleNavigation(opts: { span: placeholderSpan, routeName: name, pathname: location.pathname, + locationKey, isPlaceholder: true, }); @@ -766,7 +838,12 @@ export function handleNavigation(opts: { if (navigationSpan) { // Update the map with the real span (isPlaceholder omitted, defaults to false) - activeNavigationSpans.set(client, { span: navigationSpan, routeName: name, pathname: location.pathname }); + activeNavigationSpans.set(client, { + span: navigationSpan, + routeName: name, + pathname: location.pathname, + locationKey, + }); patchSpanEnd(navigationSpan, location, routes, basename, allRoutes, 'navigation'); } else { // If no span was created, remove the placeholder @@ -955,14 +1032,16 @@ function patchSpanEnd( const currentName = spanJson.description; const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; - // Clean up activeNavigationSpans entry for this span to prevent stale entries - const client = getClient(); - if (client && spanType === 'navigation') { - const trackedNav = activeNavigationSpans.get(client); - if (trackedNav && trackedNav.span === span) { - activeNavigationSpans.delete(client); + // Helper to clean up activeNavigationSpans after span ends + const cleanupNavigationSpan = (): void => { + const client = getClient(); + if (client && spanType === 'navigation') { + const trackedNav = activeNavigationSpans.get(client); + if (trackedNav && trackedNav.span === span) { + activeNavigationSpans.delete(client); + } } - } + }; const pendingPromises = pendingLazyRouteLoads.get(span); // Wait for lazy routes if: @@ -978,7 +1057,9 @@ function patchSpanEnd( if (shouldWaitForLazyRoutes) { if (_lazyRouteTimeout === 0) { tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutesSet); - return originalEnd(...args); + originalEnd(...args); + cleanupNavigationSpan(); + return; } const allSettled = Promise.allSettled(pendingPromises).then(() => {}); @@ -1001,15 +1082,18 @@ function patchSpanEnd( allRoutesSet, ); originalEnd(...args); + cleanupNavigationSpan(); }) .catch(() => { originalEnd(...args); + cleanupNavigationSpan(); }); return; } tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutesSet); - return originalEnd(...args); + originalEnd(...args); + cleanupNavigationSpan(); }; addNonEnumerableProperty(span as unknown as Record, patchedPropertyName, true); diff --git a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx index 4346787a111b..276a5b9950fc 100644 --- a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx +++ b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx @@ -10,7 +10,12 @@ import { createReactRouterV6CompatibleTracingIntegration, updateNavigationSpan, } from '../../src/reactrouter-compat-utils'; -import { addRoutesToAllRoutes, allRoutes } from '../../src/reactrouter-compat-utils/instrumentation'; +import { + addRoutesToAllRoutes, + allRoutes, + computeLocationKey, + shouldSkipNavigation, +} from '../../src/reactrouter-compat-utils/instrumentation'; import { resolveRouteNameAndSource, transactionNameHasWildcard } from '../../src/reactrouter-compat-utils/utils'; import type { Location, RouteObject } from '../../src/types'; @@ -36,6 +41,7 @@ vi.mock('@sentry/browser', async requireActual => { return { ...(actual as any), startBrowserTracingNavigationSpan: vi.fn(), + startBrowserTracingPageLoadSpan: vi.fn(), browserTracingIntegration: vi.fn(() => ({ setup: vi.fn(), afterAllSetup: vi.fn(), @@ -625,4 +631,681 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { // which is called during span.end() patching expect(mockUpdateName).toHaveBeenCalled(); // Initial set is allowed }); + + describe('computeLocationKey (pure function)', () => { + it('should include pathname, search, and hash in location key', () => { + const location: Location = { + pathname: '/search', + search: '?q=foo', + hash: '#results', + state: null, + key: 'test', + }; + + const result = computeLocationKey(location); + + expect(result).toBe('/search?q=foo#results'); + }); + + it('should differentiate locations with same pathname but different query', () => { + const loc1: Location = { pathname: '/search', search: '?q=foo', hash: '', state: null, key: 'k1' }; + const loc2: Location = { pathname: '/search', search: '?q=bar', hash: '', state: null, key: 'k2' }; + + const key1 = computeLocationKey(loc1); + const key2 = computeLocationKey(loc2); + + // Verifies that search params are included in the location key + expect(key1).not.toBe(key2); + expect(key1).toBe('/search?q=foo'); + expect(key2).toBe('/search?q=bar'); + }); + + it('should differentiate locations with same pathname but different hash', () => { + const loc1: Location = { pathname: '/page', search: '', hash: '#section1', state: null, key: 'k1' }; + const loc2: Location = { pathname: '/page', search: '', hash: '#section2', state: null, key: 'k2' }; + + const key1 = computeLocationKey(loc1); + const key2 = computeLocationKey(loc2); + + // Verifies that hash values are included in the location key + expect(key1).not.toBe(key2); + expect(key1).toBe('/page#section1'); + expect(key2).toBe('/page#section2'); + }); + + it('should produce same key for identical locations', () => { + const loc1: Location = { pathname: '/users', search: '?id=123', hash: '#profile', state: null, key: 'k1' }; + const loc2: Location = { pathname: '/users', search: '?id=123', hash: '#profile', state: null, key: 'k2' }; + + expect(computeLocationKey(loc1)).toBe(computeLocationKey(loc2)); + }); + + it('should normalize undefined/null search and hash to empty strings (partial location objects)', () => { + // When receives a string, React Router creates a partial location + // with search: undefined and hash: undefined. We must normalize these to empty strings + // to match the keys from full location objects (which have search: '' and hash: ''). + // This prevents duplicate navigation spans when using prop (common in modal routes). + const partialLocation: Location = { + pathname: '/users', + search: undefined as unknown as string, + hash: undefined as unknown as string, + state: null, + key: 'test1', + }; + + const fullLocation: Location = { + pathname: '/users', + search: '', + hash: '', + state: null, + key: 'test2', + }; + + const partialKey = computeLocationKey(partialLocation); + const fullKey = computeLocationKey(fullLocation); + + // Verifies that undefined values are normalized to empty strings, preventing + // '/usersundefinedundefined' !== '/users' mismatches + expect(partialKey).toBe('/users'); + expect(fullKey).toBe('/users'); + expect(partialKey).toBe(fullKey); + }); + + it('should normalize null search and hash to empty strings', () => { + const locationWithNulls: Location = { + pathname: '/products', + search: null as unknown as string, + hash: null as unknown as string, + state: null, + key: 'test3', + }; + + const locationWithEmptyStrings: Location = { + pathname: '/products', + search: '', + hash: '', + state: null, + key: 'test4', + }; + + expect(computeLocationKey(locationWithNulls)).toBe('/products'); + expect(computeLocationKey(locationWithEmptyStrings)).toBe('/products'); + expect(computeLocationKey(locationWithNulls)).toBe(computeLocationKey(locationWithEmptyStrings)); + }); + }); + + describe('shouldSkipNavigation (pure function - duplicate detection logic)', () => { + const mockSpan: Span = { updateName: vi.fn(), setAttribute: vi.fn(), end: vi.fn() } as unknown as Span; + + it('should not skip when no tracked navigation exists', () => { + const result = shouldSkipNavigation(undefined, '/users', '/users/:id', false); + + expect(result).toEqual({ skip: false, shouldUpdate: false }); + }); + + it('should skip placeholder navigations for same locationKey', () => { + const trackedNav = { + span: mockSpan, + routeName: '/search', + pathname: '/search', + locationKey: '/search?q=foo', + isPlaceholder: true, + }; + + const result = shouldSkipNavigation(trackedNav, '/search?q=foo', '/search', false); + + // Verifies that placeholder navigations for the same locationKey are skipped + expect(result.skip).toBe(true); + expect(result.shouldUpdate).toBe(false); + }); + + it('should NOT skip placeholder navigations for different locationKey (query change)', () => { + const trackedNav = { + span: mockSpan, + routeName: '/search', + pathname: '/search', + locationKey: '/search?q=foo', + isPlaceholder: true, + }; + + const result = shouldSkipNavigation(trackedNav, '/search?q=bar', '/search', false); + + // Verifies that different locationKeys allow new navigation even with same pathname + expect(result.skip).toBe(false); + expect(result.shouldUpdate).toBe(false); + }); + + it('should skip real span navigations for same locationKey when span has not ended', () => { + const trackedNav = { + span: mockSpan, + routeName: '/users/:id', + pathname: '/users/123', + locationKey: '/users/123?tab=profile', + isPlaceholder: false, + }; + + const result = shouldSkipNavigation(trackedNav, '/users/123?tab=profile', '/users/:id', false); + + // Verifies that duplicate navigations are blocked when span hasn't ended + expect(result.skip).toBe(true); + }); + + it('should NOT skip real span navigations for different locationKey (query change)', () => { + const trackedNav = { + span: mockSpan, + routeName: '/users/:id', + pathname: '/users/123', + locationKey: '/users/123?tab=profile', + isPlaceholder: false, + }; + + const result = shouldSkipNavigation(trackedNav, '/users/123?tab=settings', '/users/:id', false); + + // Verifies that different locationKeys allow new navigation even with same pathname + expect(result.skip).toBe(false); + }); + + it('should NOT skip when tracked span has ended', () => { + const trackedNav = { + span: mockSpan, + routeName: '/users/:id', + pathname: '/users/123', + locationKey: '/users/123', + isPlaceholder: false, + }; + + const result = shouldSkipNavigation(trackedNav, '/users/123', '/users/:id', true); + + // Allow new navigation when previous span has ended + expect(result.skip).toBe(false); + }); + + it('should set shouldUpdate=true for wildcard to parameterized upgrade', () => { + const trackedNav = { + span: mockSpan, + routeName: '/users/*', + pathname: '/users/123', + locationKey: '/users/123', + isPlaceholder: false, + }; + + const result = shouldSkipNavigation(trackedNav, '/users/123', '/users/:id', false); + + // Verifies that wildcard names are upgraded to parameterized routes + expect(result.skip).toBe(true); + expect(result.shouldUpdate).toBe(true); + }); + + it('should NOT set shouldUpdate=true when both names are wildcards', () => { + const trackedNav = { + span: mockSpan, + routeName: '/users/*', + pathname: '/users/123', + locationKey: '/users/123', + isPlaceholder: false, + }; + + const result = shouldSkipNavigation(trackedNav, '/users/123', '/users/*', false); + + expect(result.skip).toBe(true); + expect(result.shouldUpdate).toBe(false); + }); + }); + + describe('handleNavigation integration (verifies wiring to pure functions)', () => { + // Verifies that handleNavigation correctly uses computeLocationKey and shouldSkipNavigation + + let mockNavigationSpan: Span; + + beforeEach(async () => { + // Reset all mocks + vi.clearAllMocks(); + + // Import fresh modules to reset internal state + const coreModule = await import('@sentry/core'); + const browserModule = await import('@sentry/browser'); + const instrumentationModule = await import('../../src/reactrouter-compat-utils/instrumentation'); + + // Create a mock span with end() that captures callback + mockNavigationSpan = { + updateName: vi.fn(), + setAttribute: vi.fn(), + end: vi.fn(), + } as unknown as Span; + + // Mock getClient to return a client that's registered for instrumentation + const mockClient = { + addIntegration: vi.fn(), + emit: vi.fn(), + on: vi.fn(), + getOptions: vi.fn(() => ({})), + } as unknown as Client; + vi.mocked(coreModule.getClient).mockReturnValue(mockClient); + + // Mock startBrowserTracingPageLoadSpan to avoid pageload span creation during setup + vi.mocked(browserModule.startBrowserTracingPageLoadSpan).mockReturnValue(undefined); + + // Register client for instrumentation by adding it to the internal set + const integration = instrumentationModule.createReactRouterV6CompatibleTracingIntegration({ + useEffect: vi.fn(), + useLocation: vi.fn(), + useNavigationType: vi.fn(), + createRoutesFromChildren: vi.fn(), + matchRoutes: vi.fn(), + }); + integration.afterAllSetup(mockClient); + + // Mock startBrowserTracingNavigationSpan to return our mock span + vi.mocked(browserModule.startBrowserTracingNavigationSpan).mockReturnValue(mockNavigationSpan); + + // Mock spanToJSON to return different values for different calls + vi.mocked(coreModule.spanToJSON).mockReturnValue({ op: 'navigation' } as any); + + // Mock getActiveRootSpan to return undefined (no pageload span) + vi.mocked(coreModule.getActiveSpan).mockReturnValue(undefined); + }); + + it('creates navigation span and uses computeLocationKey for tracking', async () => { + const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation'); + const { startBrowserTracingNavigationSpan } = await import('@sentry/browser'); + const { resolveRouteNameAndSource } = await import('../../src/reactrouter-compat-utils/utils'); + + // Mock to return a specific route name + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/search', 'route']); + + const location: Location = { + pathname: '/search', + search: '?q=foo', + hash: '#results', + state: null, + key: 'test1', + }; + + const matches = [ + { + pathname: '/search', + pathnameBase: '/search', + route: { path: '/search', element:

}, + params: {}, + }, + ]; + + handleNavigation({ + location, + routes: [{ path: '/search', element:
}], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Verifies that handleNavigation calls startBrowserTracingNavigationSpan + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledWith( + expect.objectContaining({ emit: expect.any(Function) }), // client + expect.objectContaining({ + name: '/search', + attributes: expect.objectContaining({ + 'sentry.op': 'navigation', + 'sentry.source': 'route', + }), + }), + ); + }); + + it('blocks duplicate navigation for exact same locationKey (pathname+query+hash)', async () => { + const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation'); + const { startBrowserTracingNavigationSpan } = await import('@sentry/browser'); + const { spanToJSON } = await import('@sentry/core'); + + const location: Location = { + pathname: '/search', + search: '?q=foo', + hash: '#results', + state: null, + key: 'test1', + }; + + const matches = [ + { + pathname: '/search', + pathnameBase: '/search', + route: { path: '/search', element:
}, + params: {}, + }, + ]; + + // First navigation - should create span + handleNavigation({ + location, + routes: [{ path: '/search', element:
}], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Mock spanToJSON to indicate span hasn't ended yet + vi.mocked(spanToJSON).mockReturnValue({ op: 'navigation' } as any); + + // Second navigation - exact same location, should be blocked + handleNavigation({ + location: { ...location, key: 'test2' }, // Different key, same location + routes: [{ path: '/search', element:
}], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Verifies that duplicate detection uses locationKey (not just pathname) + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); // Only first call + }); + + it('allows navigation for same pathname but different query string', async () => { + const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation'); + const { startBrowserTracingNavigationSpan } = await import('@sentry/browser'); + const { spanToJSON } = await import('@sentry/core'); + + const location1: Location = { + pathname: '/search', + search: '?q=foo', + hash: '', + state: null, + key: 'test1', + }; + + const matches = [ + { + pathname: '/search', + pathnameBase: '/search', + route: { path: '/search', element:
}, + params: {}, + }, + ]; + + // First navigation + handleNavigation({ + location: location1, + routes: [{ path: '/search', element:
}], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Mock spanToJSON to indicate span hasn't ended yet + vi.mocked(spanToJSON).mockReturnValue({ op: 'navigation' } as any); + + // Second navigation - same pathname, different query + const location2: Location = { + pathname: '/search', + search: '?q=bar', + hash: '', + state: null, + key: 'test2', + }; + + handleNavigation({ + location: location2, + routes: [{ path: '/search', element:
}], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Verifies that query params are included in locationKey for duplicate detection + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); // Both calls should create spans + }); + + it('allows navigation for same pathname but different hash', async () => { + const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation'); + const { startBrowserTracingNavigationSpan } = await import('@sentry/browser'); + const { spanToJSON } = await import('@sentry/core'); + + const location1: Location = { + pathname: '/page', + search: '', + hash: '#section1', + state: null, + key: 'test1', + }; + + const matches = [ + { + pathname: '/page', + pathnameBase: '/page', + route: { path: '/page', element:
}, + params: {}, + }, + ]; + + // First navigation + handleNavigation({ + location: location1, + routes: [{ path: '/page', element:
}], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Mock spanToJSON to indicate span hasn't ended yet + vi.mocked(spanToJSON).mockReturnValue({ op: 'navigation' } as any); + + // Second navigation - same pathname, different hash + const location2: Location = { + pathname: '/page', + search: '', + hash: '#section2', + state: null, + key: 'test2', + }; + + handleNavigation({ + location: location2, + routes: [{ path: '/page', element:
}], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Verifies that hash values are included in locationKey for duplicate detection + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); // Both calls should create spans + }); + + it('updates wildcard span when better parameterized name becomes available', async () => { + const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation'); + const { startBrowserTracingNavigationSpan } = await import('@sentry/browser'); + const { spanToJSON } = await import('@sentry/core'); + const { transactionNameHasWildcard, resolveRouteNameAndSource } = await import( + '../../src/reactrouter-compat-utils/utils' + ); + + const location: Location = { + pathname: '/users/123', + search: '', + hash: '', + state: null, + key: 'test1', + }; + + const matches = [ + { + pathname: '/users/123', + pathnameBase: '/users', + route: { path: '/users/*', element:
}, + params: { '*': '123' }, + }, + ]; + + // First navigation - resolves to wildcard name + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/*', 'route']); + // Mock transactionNameHasWildcard to return true for wildcards, false for parameterized + vi.mocked(transactionNameHasWildcard).mockImplementation((name: string) => { + return name.includes('/*') || name === '*' || name.endsWith('*'); + }); + + handleNavigation({ + location, + routes: [{ path: '/users/*', element:
}], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + const firstSpan = mockNavigationSpan; + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + + // Mock spanToJSON to indicate span hasn't ended yet and has wildcard name + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: '/users/*', + data: { 'sentry.source': 'route' }, + } as any); + + // Second navigation - same location but better parameterized name available + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/:id', 'route']); + + handleNavigation({ + location: { ...location, key: 'test2' }, + routes: [{ path: '/users/:id', element:
}], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Verifies that wildcard span names are upgraded when parameterized routes become available + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(vi.mocked(firstSpan.updateName)).toHaveBeenCalledWith('/users/:id'); + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); // No new span created + }); + + it('prevents duplicate spans when prop is a string (partial location)', async () => { + // This test verifies the fix for the bug where creates + // a partial location object with search: undefined and hash: undefined, which + // would result in a different locationKey ('/usersundefinedundefined' vs '/users') + // causing duplicate navigation spans. + const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation'); + const { startBrowserTracingNavigationSpan } = await import('@sentry/browser'); + const { spanToJSON } = await import('@sentry/core'); + const { resolveRouteNameAndSource } = await import('../../src/reactrouter-compat-utils/utils'); + + // Mock resolveRouteNameAndSource to return consistent route name + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users', 'route']); + + const matches = [ + { + pathname: '/users', + pathnameBase: '/users', + route: { path: '/users', element:
}, + params: {}, + }, + ]; + + // First call: Partial location (from ) + // React Router creates location with undefined search and hash + const partialLocation: Location = { + pathname: '/users', + search: undefined as unknown as string, + hash: undefined as unknown as string, + state: null, + key: 'test1', + }; + + handleNavigation({ + location: partialLocation, + routes: [{ path: '/users', element:
}], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + + // Mock spanToJSON to indicate span hasn't ended yet + vi.mocked(spanToJSON).mockReturnValue({ op: 'navigation' } as any); + + // Second call: Full location (from router.state) + // React Router provides location with empty string search and hash + const fullLocation: Location = { + pathname: '/users', + search: '', + hash: '', + state: null, + key: 'test2', + }; + + handleNavigation({ + location: fullLocation, + routes: [{ path: '/users', element:
}], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Verifies that undefined values are normalized, preventing duplicate spans + // (without normalization, '/usersundefinedundefined' != '/users' would create 2 spans) + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + }); + }); + + describe('SSR-safe RAF fallback (scheduleCallback/cancelScheduledCallback)', () => { + // These tests verify that the RAF fallback works correctly in SSR environments + + it('uses requestAnimationFrame when available', () => { + // Save original RAF + const originalRAF = window.requestAnimationFrame; + const rafSpy = vi.fn((cb: () => void) => { + cb(); + return 123; + }); + window.requestAnimationFrame = rafSpy; + + try { + // Import module to trigger RAF usage + const scheduleCallback = (callback: () => void): number => { + if (window?.requestAnimationFrame) { + return window.requestAnimationFrame(callback); + } + return setTimeout(callback, 0) as unknown as number; + }; + + const mockCallback = vi.fn(); + scheduleCallback(mockCallback); + + // Verifies that requestAnimationFrame is used when available + expect(rafSpy).toHaveBeenCalled(); + expect(mockCallback).toHaveBeenCalled(); + } finally { + window.requestAnimationFrame = originalRAF; + } + }); + + it('falls back to setTimeout when requestAnimationFrame is unavailable (SSR)', () => { + // Simulate SSR by removing RAF + const originalRAF = window.requestAnimationFrame; + const originalCAF = window.cancelAnimationFrame; + // @ts-expect-error - Simulating SSR environment + delete window.requestAnimationFrame; + // @ts-expect-error - Simulating SSR environment + delete window.cancelAnimationFrame; + + try { + const timeoutSpy = vi.spyOn(global, 'setTimeout'); + + // Import module to trigger setTimeout fallback + const scheduleCallback = (callback: () => void): number => { + if (window?.requestAnimationFrame) { + return window.requestAnimationFrame(callback); + } + return setTimeout(callback, 0) as unknown as number; + }; + + const mockCallback = vi.fn(); + scheduleCallback(mockCallback); + + // Verifies that setTimeout is used when requestAnimationFrame is unavailable + expect(timeoutSpy).toHaveBeenCalledWith(mockCallback, 0); + } finally { + window.requestAnimationFrame = originalRAF; + window.cancelAnimationFrame = originalCAF; + } + }); + }); }); From 420e93d13ed90fd906e2f9c339f61b58a380520b Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Mon, 17 Nov 2025 17:00:26 +0000 Subject: [PATCH 16/19] Set `__sentry_navigation_name_set__ ` --- .../instrumentation.tsx | 31 ++++--------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index d4ebc93ab7e9..84fd031a54c5 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -89,23 +89,8 @@ function cancelScheduledCallback(id: number): void { } /** - * Computes a location key that uniquely identifies a navigation including pathname, search, and hash. - * - * Normalizes undefined/null search and hash to empty strings to ensure consistency between - * partial location objects (from ) and full location objects - * (from history state). This prevents duplicate navigation spans when using the location - * prop with string values (common in modal routes and SSR). - * + * Computes location key for duplicate detection. Normalizes undefined/null to empty strings. * Exported for testing. - * - * @example - * // Partial location (from ) - * computeLocationKey({ pathname: '/users', search: undefined, hash: undefined }) - * // Returns: '/users' - * - * // Full location (from history) - * computeLocationKey({ pathname: '/users', search: '', hash: '' }) - * // Returns: '/users' (same key - duplicate detection works correctly) */ export function computeLocationKey(location: Location): string { return `${location.pathname}${location.search || ''}${location.hash || ''}`; @@ -184,11 +169,6 @@ export interface ReactRouterOptions { * * Defaults to 3× the configured `idleTimeout` (default: 3000ms). * - * **Note**: This option only works with data routers (createBrowserRouter/createMemoryRouter) - * that use `patchRoutesOnNavigation`. Component-based routes (/useRoutes) that use - * React.lazy() for code splitting cannot track lazy loads at the router level, so spans - * will finalize immediately regardless of this setting. - * * @default idleTimeout * 3 */ lazyRouteTimeout?: number; @@ -570,10 +550,6 @@ export function createReactRouterV6CompatibleTracingIntegration( setup(client) { integration.setup(client); - // Get idleTimeout from browserTracingIntegration options (passed through) - // idleTimeout from browserTracingIntegration (default: 1000ms) - // Note: options already contains idleTimeout if user passed it to browserTracingIntegration - // Calculate default: 3× idleTimeout, allow explicit override const defaultMaxWait = (options.idleTimeout ?? 1000) * 3; const configuredMaxWait = lazyRouteTimeout ?? defaultMaxWait; @@ -807,6 +783,11 @@ export function handleNavigation(opts: { const oldName = trackedNav.routeName; trackedNav.span.updateName(name); trackedNav.span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source as 'route' | 'url' | 'custom'); + addNonEnumerableProperty( + trackedNav.span as { __sentry_navigation_name_set__?: boolean }, + '__sentry_navigation_name_set__', + true, + ); trackedNav.routeName = name; DEBUG_BUILD && debug.log(`[Tracing] Updated navigation span name from "${oldName}" to "${name}"`); } else { From 15f8719c657bcb437e87e929ecfeec0b6a102837 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 20 Nov 2025 01:04:57 +0000 Subject: [PATCH 17/19] Add E2E tests --- .../react-router-7-lazy-routes/src/index.tsx | 63 +++- .../src/pages/Deep.tsx | 12 + .../src/pages/DelayedLazyRoute.tsx | 50 +++ .../src/pages/Index.tsx | 12 + .../src/pages/deep/Level1Routes.tsx | 11 + .../src/pages/deep/Level2Routes.tsx | 14 + .../src/pages/deep/Level3.tsx | 13 + .../tests/timeout-behavior.test.ts | 152 +++++++++ .../tests/transactions.test.ts | 295 ++++++++++++++++++ 9 files changed, 619 insertions(+), 3 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Deep.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/DelayedLazyRoute.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level1Routes.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level2Routes.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level3.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behavior.test.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx index 521048fd18f4..7787b60be398 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/react'; import React from 'react'; import ReactDOM from 'react-dom/client'; import { - Navigate, PatchRoutesOnNavigationFunction, RouterProvider, createBrowserRouter, @@ -12,6 +11,49 @@ import { useNavigationType, } from 'react-router-dom'; import Index from './pages/Index'; +import Deep from './pages/Deep'; + +function getRuntimeConfig(): { lazyRouteTimeout?: number; idleTimeout?: number } { + if (typeof window === 'undefined') { + return {}; + } + + try { + const url = new URL(window.location.href); + const timeoutParam = url.searchParams.get('timeout'); + const idleTimeoutParam = url.searchParams.get('idleTimeout'); + + let lazyRouteTimeout: number | undefined = undefined; + if (timeoutParam) { + if (timeoutParam === 'Infinity') { + lazyRouteTimeout = Infinity; + } else { + const parsed = parseInt(timeoutParam, 10); + if (!isNaN(parsed)) { + lazyRouteTimeout = parsed; + } + } + } + + let idleTimeout: number | undefined = undefined; + if (idleTimeoutParam) { + const parsed = parseInt(idleTimeoutParam, 10); + if (!isNaN(parsed)) { + idleTimeout = parsed; + } + } + + return { + lazyRouteTimeout, + idleTimeout, + }; + } catch (error) { + console.warn('Failed to read runtime config, falling back to defaults', error); + return {}; + } +} + +const runtimeConfig = getRuntimeConfig(); Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions @@ -25,6 +67,8 @@ Sentry.init({ matchRoutes, trackFetchStreamPerformance: true, enableAsyncRouteHandlers: true, + lazyRouteTimeout: runtimeConfig.lazyRouteTimeout, + idleTimeout: runtimeConfig.idleTimeout, }), ], // We recommend adjusting this value in production, or using tracesSampler @@ -66,8 +110,21 @@ const router = sentryCreateBrowserRouter( element: <>Hello World, }, { - path: '*', - element: , + path: '/delayed-lazy/:id', + lazy: async () => { + // Simulate slow lazy route loading (400ms delay) + await new Promise(resolve => setTimeout(resolve, 400)); + return { + Component: (await import('./pages/DelayedLazyRoute')).default, + }; + }, + }, + { + path: '/deep', + element: , + handle: { + lazyChildren: () => import('./pages/deep/Level1Routes').then(module => module.level2Routes), + }, }, ], { diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Deep.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Deep.tsx new file mode 100644 index 000000000000..c68f7b781e77 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Deep.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { Outlet } from 'react-router-dom'; + +export default function Deep() { + return ( +
+

Deep Route Root

+

You are at the deep route root

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/DelayedLazyRoute.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/DelayedLazyRoute.tsx new file mode 100644 index 000000000000..41e5ba5463be --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/DelayedLazyRoute.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Link, useParams, useLocation, useSearchParams } from 'react-router-dom'; + +const DelayedLazyRoute = () => { + const { id } = useParams<{ id: string }>(); + const location = useLocation(); + const [searchParams] = useSearchParams(); + const view = searchParams.get('view') || 'none'; + const source = searchParams.get('source') || 'none'; + + return ( +
+

Delayed Lazy Route

+

ID: {id}

+

{location.pathname}

+ +

{location.hash}

+

View: {view}

+

Source: {source}

+ + +
+ ); +}; + +export default DelayedLazyRoute; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx index 3053aa57b887..21b965f571f3 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx @@ -19,6 +19,18 @@ const Index = () => { Navigate to Long Running Lazy Route +
+ + Navigate to Delayed Lazy Parameterized Route + +
+ + Navigate to Delayed Lazy with Query Param + +
+ + Navigate to Deep Nested Route (3 levels, 900ms total) + ); }; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level1Routes.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level1Routes.tsx new file mode 100644 index 000000000000..0e0887b8850b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level1Routes.tsx @@ -0,0 +1,11 @@ +// Delay: 300ms before module loads +await new Promise(resolve => setTimeout(resolve, 300)); + +export const level2Routes = [ + { + path: 'level2', + handle: { + lazyChildren: () => import('./Level2Routes').then(module => module.level3Routes), + }, + }, +]; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level2Routes.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level2Routes.tsx new file mode 100644 index 000000000000..43671e1b7eee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level2Routes.tsx @@ -0,0 +1,14 @@ +// Delay: 300ms before module loads +await new Promise(resolve => setTimeout(resolve, 300)); + +export const level3Routes = [ + { + path: 'level3/:id', + lazy: async () => { + await new Promise(resolve => setTimeout(resolve, 300)); + return { + Component: (await import('./Level3')).default, + }; + }, + }, +]; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level3.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level3.tsx new file mode 100644 index 000000000000..e44ecc7da655 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level3.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; + +export default function Level3() { + const { id } = useParams(); + return ( +
+

Level 3 Deep Route

+

Deeply nested route loaded!

+

ID: {id}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behavior.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behavior.test.ts new file mode 100644 index 000000000000..5094f7a1defe --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behavior.test.ts @@ -0,0 +1,152 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('lazyRouteTimeout: Routes load within timeout window', async ({ page }) => { + const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + console.log('[WAIT] Transaction Event:', JSON.stringify(transactionEvent, null, 2)); + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction.includes('deep') + ); + }); + + // Route takes ~900ms, timeout allows 1050ms (50 + 1000) + // Routes will load in time → parameterized name + await page.goto('/?idleTimeout=50&timeout=1000'); + + const navigationLink = page.locator('id=navigation-to-deep'); + await expect(navigationLink).toBeVisible(); + await navigationLink.click(); + + const event = await transactionPromise; + + console.log('[TEST] Routes load within timeout:'); + console.log(' Transaction:', event.transaction); + console.log(' Source:', event.contexts?.trace?.data?.['sentry.source']); + console.log(' Finish reason:', event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']); + + // Should get full parameterized route + expect(event.transaction).toBe('/deep/level2/level3/:id'); + expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); +}); + +test('lazyRouteTimeout: Infinity timeout always waits for routes', async ({ page }) => { + const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + console.log('[WAIT] Transaction Event:', JSON.stringify(transactionEvent, null, 2)); + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction.includes('deep') + ); + }); + + // Infinity timeout → waits however long needed + await page.goto('/?idleTimeout=50&timeout=Infinity'); + + const navigationLink = page.locator('id=navigation-to-deep'); + await expect(navigationLink).toBeVisible(); + await navigationLink.click(); + + const event = await transactionPromise; + + console.log('[TEST] Infinity timeout:'); + console.log(' Transaction:', event.transaction); + console.log(' Source:', event.contexts?.trace?.data?.['sentry.source']); + + // Should wait indefinitely and get full route + expect(event.transaction).toBe('/deep/level2/level3/:id'); + expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); +}); + +test('idleTimeout: Captures all activity with increased timeout', async ({ page }) => { + const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + console.log('[WAIT] Transaction Event:', JSON.stringify(transactionEvent, null, 2)); + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction.includes('deep') + ); + }); + + // High idleTimeout (5000ms) ensures transaction captures all lazy loading activity + await page.goto('/?idleTimeout=5000'); + + const navigationLink = page.locator('id=navigation-to-deep'); + await expect(navigationLink).toBeVisible(); + await navigationLink.click(); + + const event = await transactionPromise; + + console.log('[TEST] Increased idleTimeout:'); + console.log(' Transaction:', event.transaction); + console.log(' Duration:', event.timestamp! - event.start_timestamp); + + expect(event.transaction).toBe('/deep/level2/level3/:id'); + expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); + + // Transaction should wait for full idle timeout (5+ seconds) + const duration = event.timestamp! - event.start_timestamp; + expect(duration).toBeGreaterThan(5.0); + expect(duration).toBeLessThan(7.0); +}); + +test('idleTimeout: Finishes prematurely with low timeout', async ({ page }) => { + const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + console.log('[WAIT] Transaction Event:', JSON.stringify(transactionEvent, null, 2)); + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction.includes('deep') + ); + }); + + // Very low idleTimeout (50ms) and lazyRouteTimeout (100ms) + // Transaction finishes quickly, but still gets parameterized route name + await page.goto('/?idleTimeout=50&timeout=100'); + + const navigationLink = page.locator('id=navigation-to-deep'); + await expect(navigationLink).toBeVisible(); + await navigationLink.click(); + + const event = await transactionPromise; + + console.log('[TEST] Low idleTimeout:'); + console.log(' Transaction:', event.transaction); + console.log(' Duration:', event.timestamp! - event.start_timestamp); + + expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); + expect(event.transaction).toBe('/deep/level2/level3/:id'); + expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route'); + + // Transaction should finish quickly (< 200ms) + const duration = event.timestamp! - event.start_timestamp; + expect(duration).toBeLessThan(0.2); +}); + +test('idleTimeout: Pageload on deeply nested route', async ({ page }) => { + const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + console.log('[WAIT] Transaction Event:', JSON.stringify(transactionEvent, null, 2)); + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'pageload' && + transactionEvent.transaction.includes('deep') + ); + }); + + // Direct pageload to deeply nested route (not navigation) + await page.goto('/deep/level2/level3/12345'); + + const pageloadEvent = await pageloadPromise; + + console.log('[TEST] Pageload on nested route:'); + console.log(' Transaction:', pageloadEvent.transaction); + console.log(' Op:', pageloadEvent.contexts?.trace?.op); + + expect(pageloadEvent.transaction).toBe('/deep/level2/level3/:id'); + expect(pageloadEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(pageloadEvent.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts index a57199c52633..ac0dc36c7961 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts @@ -587,3 +587,298 @@ test('Creates separate transactions for rapid consecutive navigations', async ({ expect(secondSpanId).not.toBe(thirdSpanId); expect(firstSpanId).not.toBe(thirdSpanId); }); + +test('Creates pageload transaction with parameterized route for delayed lazy route', async ({ page }) => { + const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'pageload' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + await page.goto('/delayed-lazy/123'); + + const pageloadEvent = await pageloadPromise; + + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + await expect(page.locator('id=delayed-lazy-id')).toHaveText('ID: 123'); + await expect(page.locator('id=delayed-lazy-path')).toHaveText('/delayed-lazy/123'); + + expect(pageloadEvent.transaction).toBe('/delayed-lazy/:id'); + expect(pageloadEvent.contexts?.trace?.op).toBe('pageload'); + expect(pageloadEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); +}); + +test('Creates navigation transaction with parameterized route for delayed lazy route', async ({ page }) => { + await page.goto('/'); + + const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + const navigationLink = page.locator('id=navigation-to-delayed-lazy'); + await expect(navigationLink).toBeVisible(); + await navigationLink.click(); + + const navigationEvent = await navigationPromise; + + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + await expect(page.locator('id=delayed-lazy-id')).toHaveText('ID: 123'); + await expect(page.locator('id=delayed-lazy-path')).toHaveText('/delayed-lazy/123'); + + expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(navigationEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); +}); + +test('Creates navigation transaction when navigating with query parameters from home to route', async ({ page }) => { + await page.goto('/'); + + // Navigate from / to /delayed-lazy/123?source=homepage + // This should create a navigation transaction with the parameterized route name + const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + const navigationLink = page.locator('id=navigation-to-delayed-lazy-with-query'); + await expect(navigationLink).toBeVisible(); + await navigationLink.click(); + + const navigationEvent = await navigationPromise; + + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + await expect(page.locator('id=delayed-lazy-id')).toHaveText('ID: 123'); + await expect(page.locator('id=delayed-lazy-path')).toHaveText('/delayed-lazy/123'); + await expect(page.locator('id=delayed-lazy-search')).toHaveText('?source=homepage'); + await expect(page.locator('id=delayed-lazy-source')).toHaveText('Source: homepage'); + + // Verify the navigation transaction has the correct parameterized route name + // Query parameters should NOT affect the transaction name (still /delayed-lazy/:id) + expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(navigationEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(navigationEvent.contexts?.trace?.status).toBe('ok'); +}); + +test('Creates separate navigation transaction when changing only query parameters on same route', async ({ page }) => { + await page.goto('/delayed-lazy/123'); + + // Wait for the page to fully load + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + + // Navigate from /delayed-lazy/123 to /delayed-lazy/123?view=detailed + // This is a query-only change on the same route + const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + const queryLink = page.locator('id=link-to-query-view-detailed'); + await expect(queryLink).toBeVisible(); + await queryLink.click(); + + const navigationEvent = await navigationPromise; + + // Verify query param was updated + await expect(page.locator('id=delayed-lazy-search')).toHaveText('?view=detailed'); + await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: detailed'); + + // Query-only navigation should create a navigation transaction + expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(navigationEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(navigationEvent.contexts?.trace?.status).toBe('ok'); +}); + +test('Creates separate navigation transactions for multiple query parameter changes', async ({ page }) => { + await page.goto('/delayed-lazy/123'); + + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + + // First query change: /delayed-lazy/123 -> /delayed-lazy/123?view=detailed + const firstNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + const firstQueryLink = page.locator('id=link-to-query-view-detailed'); + await expect(firstQueryLink).toBeVisible(); + await firstQueryLink.click(); + + const firstNavigationEvent = await firstNavigationPromise; + const firstTraceId = firstNavigationEvent.contexts?.trace?.trace_id; + + await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: detailed'); + + // Second query change: /delayed-lazy/123?view=detailed -> /delayed-lazy/123?view=list + const secondNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' && + transactionEvent.contexts?.trace?.trace_id !== firstTraceId + ); + }); + + const secondQueryLink = page.locator('id=link-to-query-view-list'); + await expect(secondQueryLink).toBeVisible(); + await secondQueryLink.click(); + + const secondNavigationEvent = await secondNavigationPromise; + const secondTraceId = secondNavigationEvent.contexts?.trace?.trace_id; + + await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: list'); + + // Both navigations should have created separate transactions + expect(firstNavigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(firstNavigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(secondNavigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(secondNavigationEvent.contexts?.trace?.op).toBe('navigation'); + + // Trace IDs should be different (separate transactions) + expect(firstTraceId).toBeDefined(); + expect(secondTraceId).toBeDefined(); + expect(firstTraceId).not.toBe(secondTraceId); +}); + +test('Creates navigation transaction when changing only hash on same route', async ({ page }) => { + await page.goto('/delayed-lazy/123'); + + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + + // Navigate from /delayed-lazy/123 to /delayed-lazy/123#section1 + // This is a hash-only change on the same route + const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + const hashLink = page.locator('id=link-to-hash-section1'); + await expect(hashLink).toBeVisible(); + await hashLink.click(); + + const navigationEvent = await navigationPromise; + + // Verify hash was updated + await expect(page.locator('id=delayed-lazy-hash')).toHaveText('#section1'); + + // Hash-only navigation should create a navigation transaction + expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(navigationEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(navigationEvent.contexts?.trace?.status).toBe('ok'); +}); + +test('Creates separate navigation transactions for multiple hash changes', async ({ page }) => { + await page.goto('/delayed-lazy/123'); + + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + + // First hash change: /delayed-lazy/123 -> /delayed-lazy/123#section1 + const firstNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + const firstHashLink = page.locator('id=link-to-hash-section1'); + await expect(firstHashLink).toBeVisible(); + await firstHashLink.click(); + + const firstNavigationEvent = await firstNavigationPromise; + const firstTraceId = firstNavigationEvent.contexts?.trace?.trace_id; + + await expect(page.locator('id=delayed-lazy-hash')).toHaveText('#section1'); + + // Second hash change: /delayed-lazy/123#section1 -> /delayed-lazy/123#section2 + const secondNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' && + transactionEvent.contexts?.trace?.trace_id !== firstTraceId + ); + }); + + const secondHashLink = page.locator('id=link-to-hash-section2'); + await expect(secondHashLink).toBeVisible(); + await secondHashLink.click(); + + const secondNavigationEvent = await secondNavigationPromise; + const secondTraceId = secondNavigationEvent.contexts?.trace?.trace_id; + + await expect(page.locator('id=delayed-lazy-hash')).toHaveText('#section2'); + + // Both navigations should have created separate transactions + expect(firstNavigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(firstNavigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(secondNavigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(secondNavigationEvent.contexts?.trace?.op).toBe('navigation'); + + // Trace IDs should be different (separate transactions) + expect(firstTraceId).toBeDefined(); + expect(secondTraceId).toBeDefined(); + expect(firstTraceId).not.toBe(secondTraceId); +}); + +test('Creates navigation transaction when changing both query and hash on same route', async ({ page }) => { + await page.goto('/delayed-lazy/123?view=list'); + + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: list'); + + // Navigate from /delayed-lazy/123?view=list to /delayed-lazy/123?view=grid#results + // This changes both query and hash + const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + const queryAndHashLink = page.locator('id=link-to-query-and-hash'); + await expect(queryAndHashLink).toBeVisible(); + await queryAndHashLink.click(); + + const navigationEvent = await navigationPromise; + + // Verify both query and hash were updated + await expect(page.locator('id=delayed-lazy-search')).toHaveText('?view=grid'); + await expect(page.locator('id=delayed-lazy-hash')).toHaveText('#results'); + await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: grid'); + + // Combined query + hash navigation should create a navigation transaction + expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(navigationEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(navigationEvent.contexts?.trace?.status).toBe('ok'); +}); From c29e60f29c56471b7936c45f46f335254a73d56f Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 20 Nov 2025 10:54:26 +0000 Subject: [PATCH 18/19] chore: Remove debug console.log statements from tests --- .../tests/timeout-behavior.test.ts | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behavior.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behavior.test.ts index 5094f7a1defe..531a4190cdc0 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behavior.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behavior.test.ts @@ -3,7 +3,6 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; test('lazyRouteTimeout: Routes load within timeout window', async ({ page }) => { const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - console.log('[WAIT] Transaction Event:', JSON.stringify(transactionEvent, null, 2)); return ( !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation' && @@ -21,11 +20,6 @@ test('lazyRouteTimeout: Routes load within timeout window', async ({ page }) => const event = await transactionPromise; - console.log('[TEST] Routes load within timeout:'); - console.log(' Transaction:', event.transaction); - console.log(' Source:', event.contexts?.trace?.data?.['sentry.source']); - console.log(' Finish reason:', event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']); - // Should get full parameterized route expect(event.transaction).toBe('/deep/level2/level3/:id'); expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route'); @@ -34,7 +28,6 @@ test('lazyRouteTimeout: Routes load within timeout window', async ({ page }) => test('lazyRouteTimeout: Infinity timeout always waits for routes', async ({ page }) => { const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - console.log('[WAIT] Transaction Event:', JSON.stringify(transactionEvent, null, 2)); return ( !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation' && @@ -51,10 +44,6 @@ test('lazyRouteTimeout: Infinity timeout always waits for routes', async ({ page const event = await transactionPromise; - console.log('[TEST] Infinity timeout:'); - console.log(' Transaction:', event.transaction); - console.log(' Source:', event.contexts?.trace?.data?.['sentry.source']); - // Should wait indefinitely and get full route expect(event.transaction).toBe('/deep/level2/level3/:id'); expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route'); @@ -63,7 +52,6 @@ test('lazyRouteTimeout: Infinity timeout always waits for routes', async ({ page test('idleTimeout: Captures all activity with increased timeout', async ({ page }) => { const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - console.log('[WAIT] Transaction Event:', JSON.stringify(transactionEvent, null, 2)); return ( !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation' && @@ -80,10 +68,6 @@ test('idleTimeout: Captures all activity with increased timeout', async ({ page const event = await transactionPromise; - console.log('[TEST] Increased idleTimeout:'); - console.log(' Transaction:', event.transaction); - console.log(' Duration:', event.timestamp! - event.start_timestamp); - expect(event.transaction).toBe('/deep/level2/level3/:id'); expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route'); expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); @@ -96,7 +80,6 @@ test('idleTimeout: Captures all activity with increased timeout', async ({ page test('idleTimeout: Finishes prematurely with low timeout', async ({ page }) => { const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - console.log('[WAIT] Transaction Event:', JSON.stringify(transactionEvent, null, 2)); return ( !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation' && @@ -114,10 +97,6 @@ test('idleTimeout: Finishes prematurely with low timeout', async ({ page }) => { const event = await transactionPromise; - console.log('[TEST] Low idleTimeout:'); - console.log(' Transaction:', event.transaction); - console.log(' Duration:', event.timestamp! - event.start_timestamp); - expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); expect(event.transaction).toBe('/deep/level2/level3/:id'); expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route'); @@ -129,7 +108,6 @@ test('idleTimeout: Finishes prematurely with low timeout', async ({ page }) => { test('idleTimeout: Pageload on deeply nested route', async ({ page }) => { const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - console.log('[WAIT] Transaction Event:', JSON.stringify(transactionEvent, null, 2)); return ( !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload' && @@ -142,10 +120,6 @@ test('idleTimeout: Pageload on deeply nested route', async ({ page }) => { const pageloadEvent = await pageloadPromise; - console.log('[TEST] Pageload on nested route:'); - console.log(' Transaction:', pageloadEvent.transaction); - console.log(' Op:', pageloadEvent.contexts?.trace?.op); - expect(pageloadEvent.transaction).toBe('/deep/level2/level3/:id'); expect(pageloadEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); expect(pageloadEvent.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); From 2146eb4ca81a5b1e7e4f50c355f1b148f1a704e6 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 20 Nov 2025 11:05:44 +0000 Subject: [PATCH 19/19] fix: Detect raw-to-parameterized route upgrades in cross-usage scenarios - Add isParameterizedRoute() helper to detect if route has parameters (:id) or wildcards (*) - Update shouldSkipNavigation() to detect raw path to parameterized route upgrades - Enables proper updateName() calls in cross-usage scenarios - Fixes 3 failing cross-usage navigation tests In cross-usage scenarios (e.g., wrapCreateBrowserRouter + wrapUseRoutes), the first wrapper may create a span with the raw URL path (e.g., /second-level/321/third-level/123) if nested routes aren't fully resolved yet. The second wrapper should then upgrade it to the parameterized form (e.g., /second-level/:id/third-level/:id). Previously, this upgrade wasn't detected because we only checked for wildcard-to-parameterized and length-based specificity. Now we also detect raw-to-parameterized upgrades. --- .../instrumentation.tsx | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index 4061413ddb7d..3eec7ac7006d 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -96,6 +96,14 @@ export function computeLocationKey(location: Location): string { return `${location.pathname}${location.search || ''}${location.hash || ''}`; } +/** + * Checks if a route name is parameterized (contains route parameters like :id or wildcards like *) + * vs a raw URL path. + */ +function isParameterizedRoute(routeName: string): boolean { + return routeName.includes(':') || routeName.includes('*'); +} + /** * Determines if a navigation should be skipped as a duplicate, and if an existing span should be updated. * Exported for testing. @@ -122,14 +130,17 @@ export function shouldSkipNavigation( // This allows cross-usage scenarios where the second wrapper has more complete route info const currentHasWildcard = !!trackedNav.routeName && transactionNameHasWildcard(trackedNav.routeName); const proposedHasWildcard = transactionNameHasWildcard(proposedName); + const currentIsParameterized = !!trackedNav.routeName && isParameterizedRoute(trackedNav.routeName); + const proposedIsParameterized = isParameterizedRoute(proposedName); const isWildcardUpgrade = currentHasWildcard && !proposedHasWildcard; + const isRawToParameterized = !currentIsParameterized && proposedIsParameterized; const isMoreSpecific = proposedName !== trackedNav.routeName && proposedName.length > (trackedNav.routeName?.length || 0) && !proposedHasWildcard; - const shouldUpdate = !!(trackedNav.routeName && (isWildcardUpgrade || isMoreSpecific)); + const shouldUpdate = !!(trackedNav.routeName && (isWildcardUpgrade || isRawToParameterized || isMoreSpecific)); return { skip: true, shouldUpdate }; } @@ -141,17 +152,21 @@ export function shouldSkipNavigation( // Check if we should update the span name with a better route // Allow updates if: // 1. Current has wildcard and new doesn't (wildcard → parameterized upgrade) - // 2. New name is different and more specific (longer, indicating nested routes resolved) + // 2. Current is raw path and new is parameterized (raw → parameterized upgrade) + // 3. New name is different and more specific (longer, indicating nested routes resolved) const currentHasWildcard = !!trackedNav.routeName && transactionNameHasWildcard(trackedNav.routeName); const proposedHasWildcard = transactionNameHasWildcard(proposedName); + const currentIsParameterized = !!trackedNav.routeName && isParameterizedRoute(trackedNav.routeName); + const proposedIsParameterized = isParameterizedRoute(proposedName); const isWildcardUpgrade = currentHasWildcard && !proposedHasWildcard; + const isRawToParameterized = !currentIsParameterized && proposedIsParameterized; const isMoreSpecific = proposedName !== trackedNav.routeName && proposedName.length > (trackedNav.routeName?.length || 0) && !proposedHasWildcard; - const shouldUpdate = !!(trackedNav.routeName && (isWildcardUpgrade || isMoreSpecific)); + const shouldUpdate = !!(trackedNav.routeName && (isWildcardUpgrade || isRawToParameterized || isMoreSpecific)); return { skip: true, shouldUpdate }; } @@ -802,7 +817,9 @@ export function handleNavigation(opts: { // Update placeholder's route name - the real span will be created with this name trackedNav.routeName = name; DEBUG_BUILD && - debug.log(`[Tracing] Updated placeholder navigation name from "${oldName}" to "${name}" (will apply to real span)`); + debug.log( + `[Tracing] Updated placeholder navigation name from "${oldName}" to "${name}" (will apply to real span)`, + ); } else { // Update existing real span from wildcard to parameterized route name trackedNav.span.updateName(name);