@@ -57,7 +57,7 @@ const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet<Client>();
5757// Prevents duplicate spans when router.subscribe fires multiple times
5858const activeNavigationSpans = new WeakMap <
5959 Client ,
60- { span : Span ; routeName : string ; pathname : string ; isPlaceholder ?: boolean }
60+ { span : Span ; routeName : string ; pathname : string ; locationKey : string ; isPlaceholder ?: boolean }
6161> ( ) ;
6262
6363// Exported for testing only
@@ -66,6 +66,94 @@ export const allRoutes = new Set<RouteObject>();
6666// Tracks lazy route loads to wait before finalizing span names
6767const pendingLazyRouteLoads = new WeakMap < Span , Set < Promise < unknown > > > ( ) ;
6868
69+ /**
70+ * Schedules a callback using requestAnimationFrame when available (browser),
71+ * or falls back to setTimeout for SSR environments (Node.js, createMemoryRouter tests).
72+ */
73+ function scheduleCallback ( callback : ( ) => void ) : number {
74+ if ( WINDOW ?. requestAnimationFrame ) {
75+ return WINDOW . requestAnimationFrame ( callback ) ;
76+ }
77+ return setTimeout ( callback , 0 ) as unknown as number ;
78+ }
79+
80+ /**
81+ * Cancels a scheduled callback, handling both RAF (browser) and timeout (SSR) IDs.
82+ */
83+ function cancelScheduledCallback ( id : number ) : void {
84+ if ( WINDOW ?. cancelAnimationFrame ) {
85+ WINDOW . cancelAnimationFrame ( id ) ;
86+ } else {
87+ clearTimeout ( id ) ;
88+ }
89+ }
90+
91+ /**
92+ * Computes a location key that uniquely identifies a navigation including pathname, search, and hash.
93+ *
94+ * Normalizes undefined/null search and hash to empty strings to ensure consistency between
95+ * partial location objects (from <Routes location="/path">) and full location objects
96+ * (from history state). This prevents duplicate navigation spans when using the location
97+ * prop with string values (common in modal routes and SSR).
98+ *
99+ * Exported for testing.
100+ *
101+ * @example
102+ * // Partial location (from <Routes location="/users">)
103+ * computeLocationKey({ pathname: '/users', search: undefined, hash: undefined })
104+ * // Returns: '/users'
105+ *
106+ * // Full location (from history)
107+ * computeLocationKey({ pathname: '/users', search: '', hash: '' })
108+ * // Returns: '/users' (same key - duplicate detection works correctly)
109+ */
110+ export function computeLocationKey ( location : Location ) : string {
111+ return `${ location . pathname } ${ location . search || '' } ${ location . hash || '' } ` ;
112+ }
113+
114+ /**
115+ * Determines if a navigation should be skipped as a duplicate, and if an existing span should be updated.
116+ * Exported for testing.
117+ *
118+ * @returns An object with:
119+ * - skip: boolean - Whether to skip creating a new span
120+ * - shouldUpdate: boolean - Whether to update the existing span name (wildcard upgrade)
121+ */
122+ export function shouldSkipNavigation (
123+ trackedNav :
124+ | { span : Span ; routeName : string ; pathname : string ; locationKey : string ; isPlaceholder ?: boolean }
125+ | undefined ,
126+ locationKey : string ,
127+ proposedName : string ,
128+ spanHasEnded : boolean ,
129+ ) : { skip : boolean ; shouldUpdate : boolean } {
130+ if ( ! trackedNav ) {
131+ return { skip : false , shouldUpdate : false } ;
132+ }
133+
134+ // If it's a placeholder for the same location, skip immediately (span creation in progress)
135+ if ( trackedNav . isPlaceholder && trackedNav . locationKey === locationKey ) {
136+ return { skip : true , shouldUpdate : false } ;
137+ }
138+
139+ // For real spans (not placeholders), check if duplicate by location and end status
140+ if ( ! trackedNav . isPlaceholder ) {
141+ // If tracked span is for the same location and hasn't ended yet, this is a duplicate
142+ if ( trackedNav . locationKey === locationKey && ! spanHasEnded ) {
143+ // Check if we should update from wildcard to parameterized
144+ const shouldUpdate =
145+ ! ! trackedNav . routeName &&
146+ transactionNameHasWildcard ( trackedNav . routeName ) &&
147+ ! transactionNameHasWildcard ( proposedName ) ;
148+
149+ return { skip : true , shouldUpdate } ;
150+ }
151+ }
152+
153+ // Location is different or span has ended - allow creating new span
154+ return { skip : false , shouldUpdate : false } ;
155+ }
156+
69157export interface ReactRouterOptions {
70158 useEffect : UseEffect ;
71159 useLocation : UseLocation ;
@@ -286,7 +374,8 @@ function setupRouterSubscription(
286374
287375 if ( shouldHandleNavigation ) {
288376 // Include search and hash to allow query/hash-only navigations
289- const currentLocationKey = `${ state . location . pathname } ${ state . location . search } ${ state . location . hash } ` ;
377+ // Use computeLocationKey() to ensure undefined/null values are normalized to empty strings
378+ const currentLocationKey = computeLocationKey ( state . location ) ;
290379 const navigationHandler = ( ) : void => {
291380 // Prevent multiple calls for the same location within the same navigation cycle
292381 if ( lastHandledPathname === currentLocationKey ) {
@@ -311,13 +400,13 @@ function setupRouterSubscription(
311400 }
312401 // Cancel any previously scheduled handler to avoid duplicates
313402 if ( scheduledNavigationHandler !== null ) {
314- cancelAnimationFrame ( scheduledNavigationHandler ) ;
403+ cancelScheduledCallback ( scheduledNavigationHandler ) ;
315404 }
316- scheduledNavigationHandler = requestAnimationFrame ( navigationHandler ) ;
405+ scheduledNavigationHandler = scheduleCallback ( navigationHandler ) ;
317406 } else {
318407 // Navigation completed - cancel scheduled handler if any, then call immediately
319408 if ( scheduledNavigationHandler !== null ) {
320- cancelAnimationFrame ( scheduledNavigationHandler ) ;
409+ cancelScheduledCallback ( scheduledNavigationHandler ) ;
321410 scheduledNavigationHandler = null ;
322411 }
323412 navigationHandler ( ) ;
@@ -671,7 +760,6 @@ function wrapPatchRoutesOnNavigation(
671760 } ;
672761}
673762
674- // eslint-disable-next-line complexity
675763export function handleNavigation ( opts : {
676764 location : Location ;
677765 routes : RouteObject [ ] ;
@@ -705,43 +793,26 @@ export function handleNavigation(opts: {
705793 basename ,
706794 ) ;
707795
796+ const locationKey = computeLocationKey ( location ) ;
708797 const trackedNav = activeNavigationSpans . get ( client ) ;
709798
710- // If we have a tracked navigation for this client, check if it's a duplicate
711- if ( trackedNav ) {
712- // If it's a placeholder for the same pathname, skip immediately (span creation in progress)
713- if ( trackedNav . isPlaceholder && trackedNav . pathname === location . pathname ) {
714- DEBUG_BUILD &&
715- debug . log ( `[Tracing] Skipping duplicate navigation - placeholder exists for pathname: ${ location . pathname } ` ) ;
716- return ;
717- }
718-
719- // For real spans (not placeholders), check if duplicate by pathname and end status
720- if ( ! trackedNav . isPlaceholder ) {
721- const trackedSpanJson = spanToJSON ( trackedNav . span ) ;
722- const trackedSpanHasEnded = ! ! trackedSpanJson . timestamp ;
723-
724- // If tracked span is for the same pathname and hasn't ended yet, this is a duplicate
725- if ( trackedNav . pathname === location . pathname && ! trackedSpanHasEnded ) {
726- // Check if we should update from wildcard to parameterized
727- const shouldUpdate =
728- trackedNav . routeName &&
729- transactionNameHasWildcard ( trackedNav . routeName ) &&
730- ! transactionNameHasWildcard ( name ) ;
731-
732- if ( shouldUpdate ) {
733- const oldName = trackedNav . routeName ;
734- trackedNav . span . updateName ( name ) ;
735- trackedNav . span . setAttribute ( SEMANTIC_ATTRIBUTE_SENTRY_SOURCE , source as 'route' | 'url' | 'custom' ) ;
736- // Update the tracked route name so future checks use the new name
737- trackedNav . routeName = name ;
738- DEBUG_BUILD && debug . log ( `[Tracing] Updated navigation span name from "${ oldName } " to "${ name } "` ) ;
739- }
740- return ; // Skip - duplicate (same pathname, span still active)
741- }
742- // If pathname is different or span has ended, allow creating new span
743- // This handles legitimate re-navigations like /users/123 -> /users/456
799+ // Determine if this navigation should be skipped as a duplicate
800+ const trackedSpanHasEnded =
801+ trackedNav && ! trackedNav . isPlaceholder ? ! ! spanToJSON ( trackedNav . span ) . timestamp : false ;
802+ const { skip, shouldUpdate } = shouldSkipNavigation ( trackedNav , locationKey , name , trackedSpanHasEnded ) ;
803+
804+ if ( skip ) {
805+ if ( shouldUpdate && trackedNav ) {
806+ // Update existing span from wildcard to parameterized route name
807+ const oldName = trackedNav . routeName ;
808+ trackedNav . span . updateName ( name ) ;
809+ trackedNav . span . setAttribute ( SEMANTIC_ATTRIBUTE_SENTRY_SOURCE , source as 'route' | 'url' | 'custom' ) ;
810+ trackedNav . routeName = name ;
811+ DEBUG_BUILD && debug . log ( `[Tracing] Updated navigation span name from "${ oldName } " to "${ name } "` ) ;
812+ } else {
813+ DEBUG_BUILD && debug . log ( `[Tracing] Skipping duplicate navigation for location: ${ locationKey } ` ) ;
744814 }
815+ return ;
745816 }
746817
747818 // Create new navigation span (first navigation or legitimate new navigation)
@@ -752,6 +823,7 @@ export function handleNavigation(opts: {
752823 span : placeholderSpan ,
753824 routeName : name ,
754825 pathname : location . pathname ,
826+ locationKey,
755827 isPlaceholder : true ,
756828 } ) ;
757829
@@ -766,7 +838,12 @@ export function handleNavigation(opts: {
766838
767839 if ( navigationSpan ) {
768840 // Update the map with the real span (isPlaceholder omitted, defaults to false)
769- activeNavigationSpans . set ( client , { span : navigationSpan , routeName : name , pathname : location . pathname } ) ;
841+ activeNavigationSpans . set ( client , {
842+ span : navigationSpan ,
843+ routeName : name ,
844+ pathname : location . pathname ,
845+ locationKey,
846+ } ) ;
770847 patchSpanEnd ( navigationSpan , location , routes , basename , allRoutes , 'navigation' ) ;
771848 } else {
772849 // If no span was created, remove the placeholder
@@ -955,14 +1032,16 @@ function patchSpanEnd(
9551032 const currentName = spanJson . description ;
9561033 const currentSource = spanJson . data ?. [ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE ] ;
9571034
958- // Clean up activeNavigationSpans entry for this span to prevent stale entries
959- const client = getClient ( ) ;
960- if ( client && spanType === 'navigation' ) {
961- const trackedNav = activeNavigationSpans . get ( client ) ;
962- if ( trackedNav && trackedNav . span === span ) {
963- activeNavigationSpans . delete ( client ) ;
1035+ // Helper to clean up activeNavigationSpans after span ends
1036+ const cleanupNavigationSpan = ( ) : void => {
1037+ const client = getClient ( ) ;
1038+ if ( client && spanType === 'navigation' ) {
1039+ const trackedNav = activeNavigationSpans . get ( client ) ;
1040+ if ( trackedNav && trackedNav . span === span ) {
1041+ activeNavigationSpans . delete ( client ) ;
1042+ }
9641043 }
965- }
1044+ } ;
9661045
9671046 const pendingPromises = pendingLazyRouteLoads . get ( span ) ;
9681047 // Wait for lazy routes if:
@@ -978,7 +1057,9 @@ function patchSpanEnd(
9781057 if ( shouldWaitForLazyRoutes ) {
9791058 if ( _lazyRouteTimeout === 0 ) {
9801059 tryUpdateSpanNameBeforeEnd ( span , spanJson , currentName , location , routes , basename , spanType , allRoutesSet ) ;
981- return originalEnd ( ...args ) ;
1060+ originalEnd ( ...args ) ;
1061+ cleanupNavigationSpan ( ) ;
1062+ return ;
9821063 }
9831064
9841065 const allSettled = Promise . allSettled ( pendingPromises ) . then ( ( ) => { } ) ;
@@ -1001,15 +1082,18 @@ function patchSpanEnd(
10011082 allRoutesSet ,
10021083 ) ;
10031084 originalEnd ( ...args ) ;
1085+ cleanupNavigationSpan ( ) ;
10041086 } )
10051087 . catch ( ( ) => {
10061088 originalEnd ( ...args ) ;
1089+ cleanupNavigationSpan ( ) ;
10071090 } ) ;
10081091 return ;
10091092 }
10101093
10111094 tryUpdateSpanNameBeforeEnd ( span , spanJson , currentName , location , routes , basename , spanType , allRoutesSet ) ;
1012- return originalEnd ( ...args ) ;
1095+ originalEnd ( ...args ) ;
1096+ cleanupNavigationSpan ( ) ;
10131097 } ;
10141098
10151099 addNonEnumerableProperty ( span as unknown as Record < string , boolean > , patchedPropertyName , true ) ;
0 commit comments