@@ -53,9 +53,11 @@ let _enableAsyncRouteHandlers: boolean = false;
5353const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet < Client > ( ) ;
5454
5555/**
56- * Adds resolved routes as children to the parent route .
57- * Prevents duplicate routes by checking if they already exist .
56+ * Tracks last navigation per client to prevent duplicate spans in cross-usage scenarios .
57+ * Uses 100ms window to deduplicate when multiple wrappers handle the same navigation .
5858 */
59+ const LAST_NAVIGATION_PER_CLIENT = new WeakMap < Client , { key : string ; timestamp : number } > ( ) ;
60+
5961export function addResolvedRoutesToParent ( resolvedRoutes : RouteObject [ ] , parentRoute : RouteObject ) : void {
6062 const existingChildren = parentRoute . children || [ ] ;
6163
@@ -618,6 +620,69 @@ function wrapPatchRoutesOnNavigation(
618620 } ;
619621}
620622
623+ function getNavigationKey ( location : Location ) : string {
624+ return `${ location . pathname } ${ location . search } ${ location . hash } ` ;
625+ }
626+
627+ function tryUpdateSpanName (
628+ activeSpan : Span ,
629+ currentSpanName : string | undefined ,
630+ newName : string ,
631+ newSource : string ,
632+ ) : void {
633+ const isNewNameBetter = newName !== currentSpanName && newName . includes ( ':' ) ;
634+ if ( isNewNameBetter ) {
635+ activeSpan . updateName ( newName ) ;
636+ activeSpan . setAttribute ( SEMANTIC_ATTRIBUTE_SENTRY_SOURCE , newSource as 'route' | 'url' | 'custom' ) ;
637+ }
638+ }
639+
640+ function isDuplicateNavigation ( client : Client , navigationKey : string ) : boolean {
641+ const lastNavigation = LAST_NAVIGATION_PER_CLIENT . get ( client ) ;
642+ const now = Date . now ( ) ;
643+ return ! ! ( lastNavigation && lastNavigation . key === navigationKey && now - lastNavigation . timestamp < 100 ) ;
644+ }
645+
646+ function createNavigationSpan ( opts : {
647+ client : Client ;
648+ name : string ;
649+ source : string ;
650+ version : string ;
651+ location : Location ;
652+ routes : RouteObject [ ] ;
653+ basename ?: string ;
654+ allRoutes ?: RouteObject [ ] ;
655+ navigationKey : string ;
656+ } ) : Span | undefined {
657+ const { client, name, source, version, location, routes, basename, allRoutes, navigationKey } = opts ;
658+
659+ LAST_NAVIGATION_PER_CLIENT . set ( client , {
660+ key : navigationKey ,
661+ timestamp : Date . now ( ) ,
662+ } ) ;
663+
664+ const navigationSpan = startBrowserTracingNavigationSpan ( client , {
665+ name,
666+ attributes : {
667+ [ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE ] : source as 'route' | 'url' | 'custom' ,
668+ [ SEMANTIC_ATTRIBUTE_SENTRY_OP ] : 'navigation' ,
669+ [ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN ] : `auto.navigation.react.reactrouter_v${ version } ` ,
670+ } ,
671+ } ) ;
672+
673+ if ( navigationSpan ) {
674+ patchNavigationSpanEnd ( navigationSpan , location , routes , basename , allRoutes ) ;
675+
676+ client . on ( 'spanEnd' , endedSpan => {
677+ if ( endedSpan === navigationSpan ) {
678+ LAST_NAVIGATION_PER_CLIENT . delete ( client ) ;
679+ }
680+ } ) ;
681+ }
682+
683+ return navigationSpan ;
684+ }
685+
621686export function handleNavigation ( opts : {
622687 location : Location ;
623688 routes : RouteObject [ ] ;
@@ -628,16 +693,13 @@ export function handleNavigation(opts: {
628693 allRoutes ?: RouteObject [ ] ;
629694} ) : void {
630695 const { location, routes, navigationType, version, matches, basename, allRoutes } = opts ;
631- // Use allRoutes for matching to include lazy-loaded routes
632696 const branches = Array . isArray ( matches ) ? matches : _matchRoutes ( allRoutes || routes , location , basename ) ;
633697
634698 const client = getClient ( ) ;
635699 if ( ! client || ! CLIENTS_WITH_INSTRUMENT_NAVIGATION . has ( client ) ) {
636700 return ;
637701 }
638702
639- // Avoid starting a navigation span on initial load when a pageload root span is active.
640- // This commonly happens when lazy routes resolve during the first render and React Router emits a POP.
641703 const activeRootSpan = getActiveRootSpan ( ) ;
642704 if ( activeRootSpan && spanToJSON ( activeRootSpan ) . op === 'pageload' && navigationType === 'POP' ) {
643705 return ;
@@ -655,25 +717,25 @@ export function handleNavigation(opts: {
655717 const activeSpan = getActiveSpan ( ) ;
656718 const spanJson = activeSpan && spanToJSON ( activeSpan ) ;
657719 const isAlreadyInNavigationSpan = spanJson ?. op === 'navigation' ;
658-
659- // Only skip creating a new span if we're already in a navigation span AND the route name matches.
660- // This handles cross-usage (multiple wrappers for same navigation) while allowing consecutive navigations.
661720 const isSpanForSameRoute = isAlreadyInNavigationSpan && spanJson ?. description === name ;
662721
663- if ( ! isSpanForSameRoute ) {
664- const navigationSpan = startBrowserTracingNavigationSpan ( client , {
722+ const currentNavigationKey = getNavigationKey ( location ) ;
723+ const isNavDuplicate = isDuplicateNavigation ( client , currentNavigationKey ) ;
724+
725+ if ( ! isSpanForSameRoute && ! isNavDuplicate ) {
726+ createNavigationSpan ( {
727+ client,
665728 name,
666- attributes : {
667- [ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE ] : source ,
668- [ SEMANTIC_ATTRIBUTE_SENTRY_OP ] : 'navigation' ,
669- [ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN ] : `auto.navigation.react.reactrouter_v${ version } ` ,
670- } ,
729+ source,
730+ version,
731+ location,
732+ routes,
733+ basename,
734+ allRoutes,
735+ navigationKey : currentNavigationKey ,
671736 } ) ;
672-
673- // Patch navigation span to handle early cancellation (e.g., document.hidden)
674- if ( navigationSpan ) {
675- patchNavigationSpanEnd ( navigationSpan , location , routes , basename , allRoutes ) ;
676- }
737+ } else if ( isNavDuplicate && isAlreadyInNavigationSpan && activeSpan ) {
738+ tryUpdateSpanName ( activeSpan , spanJson ?. description , name , source ) ;
677739 }
678740 }
679741}
0 commit comments