@@ -41,7 +41,7 @@ import type {
4141 UseRoutes ,
4242} from '../types' ;
4343import { checkRouteForAsyncHandler } from './lazy-routes' ;
44- import { initializeRouterUtils , resolveRouteNameAndSource } from './utils' ;
44+ import { initializeRouterUtils , resolveRouteNameAndSource , transactionNameHasWildcard } from './utils' ;
4545
4646let _useEffect : UseEffect ;
4747let _useLocation : UseLocation ;
@@ -52,6 +52,9 @@ let _enableAsyncRouteHandlers: boolean = false;
5252
5353const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet < Client > ( ) ;
5454
55+ /** Delay (ms) for lazy route updates to complete before finalizing span names. */
56+ const LAZY_ROUTE_UPDATE_DELAY_MS = 50 ;
57+
5558/**
5659 * Adds resolved routes as children to the parent route.
5760 * Prevents duplicate routes by checking if they already exist.
@@ -102,6 +105,27 @@ type V6CompatibleVersion = '6' | '7';
102105// only exported for testing purposes
103106export const allRoutes = new Set < RouteObject > ( ) ;
104107
108+ /** Tracks pending lazy route loads per span to wait before finalizing span names. */
109+ const pendingLazyRouteLoads = new WeakMap < Span , Set < Promise < unknown > > > ( ) ;
110+
111+ /** Registers a pending lazy route load promise for a span. */
112+ function trackLazyRouteLoad ( span : Span , promise : Promise < unknown > ) : void {
113+ let promises = pendingLazyRouteLoads . get ( span ) ;
114+ if ( ! promises ) {
115+ promises = new Set ( ) ;
116+ pendingLazyRouteLoads . set ( span , promises ) ;
117+ }
118+ promises . add ( promise ) ;
119+
120+ // Clean up when promise resolves/rejects
121+ promise . finally ( ( ) => {
122+ const currentPromises = pendingLazyRouteLoads . get ( span ) ;
123+ if ( currentPromises ) {
124+ currentPromises . delete ( promise ) ;
125+ }
126+ } ) ;
127+ }
128+
105129/**
106130 * Processes resolved routes by adding them to allRoutes and checking for nested async handlers.
107131 */
@@ -166,12 +190,18 @@ export function updateNavigationSpan(
166190 forceUpdate = false ,
167191 matchRoutes : MatchRoutes ,
168192) : void {
169- // Check if this span has already been named to avoid multiple updates
170- // But allow updates if this is a forced update (e.g., when lazy routes are loaded)
171- const hasBeenNamed =
172- ! forceUpdate && ( activeRootSpan as { __sentry_navigation_name_set__ ?: boolean } ) ?. __sentry_navigation_name_set__ ;
193+ const spanJson = spanToJSON ( activeRootSpan ) ;
194+ const currentName = spanJson . description ;
173195
174- if ( ! hasBeenNamed ) {
196+ // Check if this span has already been named to avoid multiple updates
197+ // But allow updates if:
198+ // 1. This is a forced update (e.g., when lazy routes are loaded)
199+ // 2. The current name has wildcards (incomplete parameterization)
200+ const hasBeenNamed = ( activeRootSpan as { __sentry_navigation_name_set__ ?: boolean } ) ?. __sentry_navigation_name_set__ ;
201+ const currentNameHasWildcard = currentName && transactionNameHasWildcard ( currentName ) ;
202+ const shouldUpdate = forceUpdate || ! hasBeenNamed || currentNameHasWildcard ;
203+
204+ if ( shouldUpdate && ! spanJson . timestamp ) {
175205 // Get fresh branches for the current location with all loaded routes
176206 const currentBranches = matchRoutes ( allRoutes , location ) ;
177207 const [ name , source ] = resolveRouteNameAndSource (
@@ -182,18 +212,20 @@ export function updateNavigationSpan(
182212 '' ,
183213 ) ;
184214
185- // Only update if we have a valid name and the span hasn't finished
186- const spanJson = spanToJSON ( activeRootSpan ) ;
187- if ( name && ! spanJson . timestamp ) {
215+ // Only update if we have a valid name and it's better than what we have
216+ const isImprovement = name && ( ! currentName || ! name . includes ( '*' ) ) ;
217+ if ( isImprovement ) {
188218 activeRootSpan . updateName ( name ) ;
189219 activeRootSpan . setAttribute ( SEMANTIC_ATTRIBUTE_SENTRY_SOURCE , source ) ;
190220
191- // Mark this span as having its name set to prevent future updates
192- addNonEnumerableProperty (
193- activeRootSpan as { __sentry_navigation_name_set__ ?: boolean } ,
194- '__sentry_navigation_name_set__' ,
195- true ,
196- ) ;
221+ // Only mark as finalized if the new name doesn't have wildcards
222+ if ( ! transactionNameHasWildcard ( name ) ) {
223+ addNonEnumerableProperty (
224+ activeRootSpan as { __sentry_navigation_name_set__ ?: boolean } ,
225+ '__sentry_navigation_name_set__' ,
226+ true ,
227+ ) ;
228+ }
197229 }
198230 }
199231}
@@ -568,6 +600,8 @@ function wrapPatchRoutesOnNavigation(
568600 // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
569601 const targetPath = ( args as any ) ?. path ;
570602
603+ const activeRootSpan = getActiveRootSpan ( ) ;
604+
571605 // For browser router, wrap the patch function to update span during patching
572606 if ( ! isMemoryRouter ) {
573607 // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
@@ -576,10 +610,10 @@ function wrapPatchRoutesOnNavigation(
576610 // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
577611 ( args as any ) . patch = ( routeId : string , children : RouteObject [ ] ) => {
578612 addRoutesToAllRoutes ( children ) ;
579- const activeRootSpan = getActiveRootSpan ( ) ;
580- if ( activeRootSpan && ( spanToJSON ( activeRootSpan ) as { op ?: string } ) . op === 'navigation' ) {
613+ const currentActiveRootSpan = getActiveRootSpan ( ) ;
614+ if ( currentActiveRootSpan && ( spanToJSON ( currentActiveRootSpan ) as { op ?: string } ) . op === 'navigation' ) {
581615 updateNavigationSpan (
582- activeRootSpan ,
616+ currentActiveRootSpan ,
583617 { pathname : targetPath , search : '' , hash : '' , state : null , key : 'default' } ,
584618 Array . from ( allRoutes ) ,
585619 true , // forceUpdate = true since we're loading lazy routes
@@ -591,33 +625,43 @@ function wrapPatchRoutesOnNavigation(
591625 }
592626 }
593627
594- const result = await originalPatchRoutes ( args ) ;
628+ // Create and track promise for this lazy load
629+ const lazyLoadPromise = ( async ( ) => {
630+ const result = await originalPatchRoutes ( args ) ;
631+
632+ // Update navigation span after routes are patched
633+ const currentActiveRootSpan = getActiveRootSpan ( ) ;
634+ if ( currentActiveRootSpan && ( spanToJSON ( currentActiveRootSpan ) as { op ?: string } ) . op === 'navigation' ) {
635+ // Determine pathname based on router type
636+ let pathname : string | undefined ;
637+ if ( isMemoryRouter ) {
638+ // For memory routers, only use targetPath
639+ pathname = targetPath ;
640+ } else {
641+ // For browser routers, use targetPath or fall back to window.location
642+ pathname = targetPath || WINDOW . location ?. pathname ;
643+ }
595644
596- // Update navigation span after routes are patched
597- const activeRootSpan = getActiveRootSpan ( ) ;
598- if ( activeRootSpan && ( spanToJSON ( activeRootSpan ) as { op ?: string } ) . op === 'navigation' ) {
599- // Determine pathname based on router type
600- let pathname : string | undefined ;
601- if ( isMemoryRouter ) {
602- // For memory routers, only use targetPath
603- pathname = targetPath ;
604- } else {
605- // For browser routers, use targetPath or fall back to window.location
606- pathname = targetPath || WINDOW . location ?. pathname ;
645+ if ( pathname ) {
646+ updateNavigationSpan (
647+ currentActiveRootSpan ,
648+ { pathname, search : '' , hash : '' , state : null , key : 'default' } ,
649+ Array . from ( allRoutes ) ,
650+ false , // forceUpdate = false since this is after lazy routes are loaded
651+ _matchRoutes ,
652+ ) ;
653+ }
607654 }
608655
609- if ( pathname ) {
610- updateNavigationSpan (
611- activeRootSpan ,
612- { pathname, search : '' , hash : '' , state : null , key : 'default' } ,
613- Array . from ( allRoutes ) ,
614- false , // forceUpdate = false since this is after lazy routes are loaded
615- _matchRoutes ,
616- ) ;
617- }
656+ return result ;
657+ } ) ( ) ;
658+
659+ // Track the promise if we have an active span
660+ if ( activeRootSpan ) {
661+ trackLazyRouteLoad ( activeRootSpan , lazyLoadPromise ) ;
618662 }
619663
620- return result ;
664+ return lazyLoadPromise ;
621665 } ,
622666 } ;
623667}
@@ -658,9 +702,20 @@ export function handleNavigation(opts: {
658702 const activeSpan = getActiveSpan ( ) ;
659703 const spanJson = activeSpan && spanToJSON ( activeSpan ) ;
660704 const isAlreadyInNavigationSpan = spanJson ?. op === 'navigation' ;
705+ const currentName = spanJson ?. description ;
706+
707+ // If we're already in a navigation span, check if we should update its name
708+ if ( isAlreadyInNavigationSpan && activeSpan ) {
709+ // Only update if the new name is better (doesn't have wildcards or is more complete)
710+ const shouldUpdate = currentName && transactionNameHasWildcard ( currentName ) && ! transactionNameHasWildcard ( name ) ;
661711
662- // Cross usage can result in multiple navigation spans being created without this check
663- if ( ! isAlreadyInNavigationSpan ) {
712+ if ( shouldUpdate ) {
713+ activeSpan . updateName ( name ) ;
714+ activeSpan . setAttribute ( SEMANTIC_ATTRIBUTE_SENTRY_SOURCE , source as 'route' | 'url' | 'custom' ) ;
715+ DEBUG_BUILD && debug . log ( `[Tracing] Updated navigation span name from "${ currentName } " to "${ name } "` ) ;
716+ }
717+ } else if ( ! isAlreadyInNavigationSpan ) {
718+ // Cross usage can result in multiple navigation spans being created without this check
664719 const navigationSpan = startBrowserTracingNavigationSpan ( client , {
665720 name,
666721 attributes : {
@@ -741,6 +796,50 @@ function updatePageloadTransaction({
741796 }
742797}
743798
799+ /** Updates span name before end using latest route information. */
800+ function tryUpdateSpanNameBeforeEnd (
801+ span : Span ,
802+ spanJson : ReturnType < typeof spanToJSON > ,
803+ currentName : string | undefined ,
804+ location : Location ,
805+ routes : RouteObject [ ] ,
806+ basename : string | undefined ,
807+ spanType : 'pageload' | 'navigation' ,
808+ allRoutes : Set < RouteObject > ,
809+ ) : void {
810+ try {
811+ const currentSource = spanJson . data ?. [ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE ] ;
812+ const hasWildcard = currentName && transactionNameHasWildcard ( currentName ) ;
813+
814+ // Only attempt update if source is not 'route' or if the name has wildcards
815+ if ( currentSource === 'route' && ! hasWildcard ) {
816+ return ;
817+ }
818+
819+ const currentAllRoutes = Array . from ( allRoutes ) ;
820+ const routesToUse = currentAllRoutes . length > 0 ? currentAllRoutes : routes ;
821+ const branches = _matchRoutes ( routesToUse , location , basename ) as unknown as RouteMatch [ ] ;
822+
823+ if ( ! branches ) {
824+ return ;
825+ }
826+
827+ const [ name , source ] = resolveRouteNameAndSource ( location , routesToUse , routesToUse , branches , basename ) ;
828+
829+ // Only update if we have a valid name and it's better than current
830+ const isImprovement = name && ( ! currentName || hasWildcard ) ;
831+ const spanNotEnded = spanType === 'pageload' || ! spanJson . timestamp ;
832+
833+ if ( isImprovement && spanNotEnded ) {
834+ span . updateName ( name ) ;
835+ span . setAttribute ( SEMANTIC_ATTRIBUTE_SENTRY_SOURCE , source ) ;
836+ }
837+ } catch ( error ) {
838+ // Silently catch errors to ensure span.end() is always called
839+ DEBUG_BUILD && debug . warn ( `Error updating span details before ending: ${ error } ` ) ;
840+ }
841+ }
842+
744843/**
745844 * Patches the span.end() method to update the transaction name one last time before the span is sent.
746845 * 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(
762861
763862 const originalEnd = span . end . bind ( span ) ;
764863
864+ let endCalled = false ;
865+
765866 span . end = function patchedEnd ( ...args ) {
766- try {
767- // Only update if the span source is not already 'route' (i.e., it hasn't been parameterized yet)
768- const spanJson = spanToJSON ( span ) ;
769- const currentSource = spanJson . data ?. [ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE ] ;
770- if ( currentSource !== 'route' ) {
771- // Last chance to update the transaction name with the latest route info
772- // Use the live global allRoutes Set to include any lazy routes loaded after patching
773- const currentAllRoutes = Array . from ( allRoutes ) ;
774- const branches = _matchRoutes (
775- currentAllRoutes . length > 0 ? currentAllRoutes : routes ,
867+ // Prevent multiple calls to end()
868+ if ( endCalled ) {
869+ return ;
870+ }
871+ endCalled = true ;
872+
873+ const spanJson = spanToJSON ( span ) ;
874+ const currentName = spanJson . description ;
875+
876+ // If we have pending lazy route loads and the current name has wildcards, delay the end slightly
877+ const pendingPromises = pendingLazyRouteLoads . get ( span ) ;
878+ if ( pendingPromises && pendingPromises . size > 0 && currentName && transactionNameHasWildcard ( currentName ) ) {
879+ // Small delay to allow in-flight lazy routes to complete
880+ setTimeout ( ( ) => {
881+ tryUpdateSpanNameBeforeEnd (
882+ span ,
883+ spanToJSON ( span ) ,
884+ spanToJSON ( span ) . description ,
776885 location ,
886+ routes ,
777887 basename ,
778- ) as unknown as RouteMatch [ ] ;
779-
780- if ( branches ) {
781- const [ name , source ] = resolveRouteNameAndSource (
782- location ,
783- routes ,
784- currentAllRoutes . length > 0 ? currentAllRoutes : routes ,
785- branches ,
786- basename ,
787- ) ;
788-
789- // Only update if we have a valid name
790- if ( name && ( spanType === 'pageload' || ! spanJson . timestamp ) ) {
791- span . updateName ( name ) ;
792- span . setAttribute ( SEMANTIC_ATTRIBUTE_SENTRY_SOURCE , source ) ;
793- }
794- }
795- }
796- } catch ( error ) {
797- // Silently catch errors to ensure span.end() is always called
798- DEBUG_BUILD && debug . warn ( `Error updating span details before ending: ${ error } ` ) ;
888+ spanType ,
889+ allRoutes ,
890+ ) ;
891+ originalEnd ( ...args ) ;
892+ } , LAZY_ROUTE_UPDATE_DELAY_MS ) ;
893+ return ;
799894 }
800895
896+ tryUpdateSpanNameBeforeEnd ( span , spanJson , currentName , location , routes , basename , spanType , allRoutes ) ;
801897 return originalEnd ( ...args ) ;
802898 } ;
803899
0 commit comments