@@ -667,14 +667,19 @@ export function handleNavigation(opts: {
667667
668668 // Cross usage can result in multiple navigation spans being created without this check
669669 if ( ! isAlreadyInNavigationSpan ) {
670- startBrowserTracingNavigationSpan ( client , {
670+ const navigationSpan = startBrowserTracingNavigationSpan ( client , {
671671 name,
672672 attributes : {
673673 [ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE ] : source ,
674674 [ SEMANTIC_ATTRIBUTE_SENTRY_OP ] : 'navigation' ,
675675 [ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN ] : `auto.navigation.react.reactrouter_v${ version } ` ,
676676 } ,
677677 } ) ;
678+ 679+ // Patch navigation span to handle early cancellation (e.g., document.hidden)
680+ if ( navigationSpan ) {
681+ patchNavigationSpanEnd ( navigationSpan , location , routes , basename , allRoutes ) ;
682+ }
678683 }
679684 }
680685}
@@ -727,27 +732,141 @@ function updatePageloadTransaction({
727732 : ( _matchRoutes ( allRoutes || routes , location , basename ) as unknown as RouteMatch [ ] ) ;
728733
729734 if ( branches ) {
730- let name ,
731- source : TransactionSource = 'url' ;
732- 733- const isInDescendantRoute = locationIsInsideDescendantRoute ( location , allRoutes || routes ) ;
734- 735- if ( isInDescendantRoute ) {
736- name = prefixWithSlash ( rebuildRoutePathFromAllRoutes ( allRoutes || routes , location ) ) ;
737- source = 'route' ;
738- }
739- 740- if ( ! isInDescendantRoute || ! name ) {
741- [ name , source ] = getNormalizedName ( routes , location , branches , basename ) ;
742- }
735+ const [ name , source ] = getTransactionNameAndSource ( location , routes , branches , basename , allRoutes ) ;
743736
744737 getCurrentScope ( ) . setTransactionName ( name || '/' ) ;
745738
746739 if ( activeRootSpan ) {
747740 activeRootSpan . updateName ( name ) ;
748741 activeRootSpan . setAttribute ( SEMANTIC_ATTRIBUTE_SENTRY_SOURCE , source ) ;
742+ 743+ // Patch span.end() to ensure we update the name one last time before the span is sent
744+ patchPageloadSpanEnd ( activeRootSpan , location , routes , basename , allRoutes ) ;
745+ }
746+ }
747+ }
748+ 749+ /**
750+ * Extracts the transaction name and source from the route information.
751+ */
752+ function getTransactionNameAndSource (
753+ location : Location ,
754+ routes : RouteObject [ ] ,
755+ branches : RouteMatch [ ] ,
756+ basename : string | undefined ,
757+ allRoutes : RouteObject [ ] | undefined ,
758+ ) : [ string , TransactionSource ] {
759+ let name : string | undefined ;
760+ let source : TransactionSource = 'url' ;
761+ 762+ const isInDescendantRoute = locationIsInsideDescendantRoute ( location , allRoutes || routes ) ;
763+ 764+ if ( isInDescendantRoute ) {
765+ name = prefixWithSlash ( rebuildRoutePathFromAllRoutes ( allRoutes || routes , location ) ) ;
766+ source = 'route' ;
767+ }
768+ 769+ if ( ! isInDescendantRoute || ! name ) {
770+ [ name , source ] = getNormalizedName ( routes , location , branches , basename ) ;
771+ }
772+ 773+ return [ name || '/' , source ] ;
774+ }
775+ 776+ /**
777+ * Patches the span.end() method to update the transaction name one last time before the span is sent.
778+ * This handles cases where the span is cancelled early (e.g., document.hidden) before lazy routes have finished loading.
779+ */
780+ function patchPageloadSpanEnd (
781+ span : Span ,
782+ location : Location ,
783+ routes : RouteObject [ ] ,
784+ basename : string | undefined ,
785+ allRoutes : RouteObject [ ] | undefined ,
786+ ) : void {
787+ const hasEndBeenPatched = ( span as { __sentry_pageload_end_patched__ ?: boolean } ) ?. __sentry_pageload_end_patched__ ;
788+ 789+ if ( hasEndBeenPatched || ! span . end ) {
790+ return ;
791+ }
792+ 793+ const originalEnd = span . end . bind ( span ) ;
794+ 795+ span . end = function patchedEnd ( ...args ) {
796+ // Only update if the span source is not already 'route' (i.e., it hasn't been parameterized yet)
797+ const spanJson = spanToJSON ( span ) ;
798+ const currentSource = spanJson . data ?. [ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE ] ;
799+ if ( currentSource !== 'route' ) {
800+ // Last chance to update the transaction name with the latest route info
801+ const branches = _matchRoutes ( allRoutes || routes , location , basename ) as unknown as RouteMatch [ ] ;
802+ 803+ if ( branches ) {
804+ const [ latestName , latestSource ] = getTransactionNameAndSource ( location , routes , branches , basename , allRoutes ) ;
805+ 806+ span . updateName ( latestName ) ;
807+ span . setAttribute ( SEMANTIC_ATTRIBUTE_SENTRY_SOURCE , latestSource ) ;
808+ }
749809 }
810+ 811+ return originalEnd ( ...args ) ;
812+ } ;
813+ 814+ // Mark this span as having its end() method patched to prevent duplicate patching
815+ addNonEnumerableProperty (
816+ span as { __sentry_pageload_end_patched__ ?: boolean } ,
817+ '__sentry_pageload_end_patched__' ,
818+ true ,
819+ ) ;
820+ }
821+ 822+ /**
823+ * Patches the navigation span.end() method to update the transaction name one last time before the span is sent.
824+ * This handles cases where the span is cancelled early (e.g., document.hidden) before lazy routes have finished loading.
825+ */
826+ function patchNavigationSpanEnd (
827+ span : Span ,
828+ location : Location ,
829+ routes : RouteObject [ ] ,
830+ basename : string | undefined ,
831+ allRoutes : RouteObject [ ] | undefined ,
832+ ) : void {
833+ const hasEndBeenPatched = ( span as { __sentry_navigation_end_patched__ ?: boolean } )
834+ ?. __sentry_navigation_end_patched__ ;
835+ 836+ if ( hasEndBeenPatched || ! span . end ) {
837+ return ;
750838 }
839+ 840+ const originalEnd = span . end . bind ( span ) ;
841+ 842+ span . end = function patchedEnd ( ...args ) {
843+ // Only update if the span source is not already 'route' (i.e., it hasn't been parameterized yet)
844+ const spanJson = spanToJSON ( span ) ;
845+ const currentSource = spanJson . data ?. [ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE ] ;
846+ if ( currentSource !== 'route' ) {
847+ // Last chance to update the transaction name with the latest route info
848+ const branches = _matchRoutes ( allRoutes || routes , location , basename ) as unknown as RouteMatch [ ] ;
849+ 850+ if ( branches ) {
851+ const [ name , source ] = resolveRouteNameAndSource ( location , routes , allRoutes || routes , branches , basename ) ;
852+ 853+ // Only update if we have a valid name and the span hasn't finished
854+ if ( name && ! spanJson . timestamp ) {
855+ span . updateName ( name ) ;
856+ span . setAttribute ( SEMANTIC_ATTRIBUTE_SENTRY_SOURCE , source ) ;
857+ }
858+ }
859+ }
860+ 861+ return originalEnd ( ...args ) ;
862+ } ;
863+ 864+ // Mark this span as having its end() method patched to prevent duplicate patching
865+ addNonEnumerableProperty (
866+ span as { __sentry_navigation_end_patched__ ?: boolean } ,
867+ '__sentry_navigation_end_patched__' ,
868+ true ,
869+ ) ;
751870}
752871
753872// eslint-disable-next-line @typescript-eslint/no-explicit-any
0 commit comments