@@ -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,146 @@ 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 } ) ?. __sentry_navigation_end_patched__ ;
834+
835+ if ( hasEndBeenPatched || ! span . end ) {
836+ return ;
750837 }
838+
839+ const originalEnd = span . end . bind ( span ) ;
840+
841+ span . end = function patchedEnd ( ...args ) {
842+ // Only update if the span source is not already 'route' (i.e., it hasn't been parameterized yet)
843+ const spanJson = spanToJSON ( span ) ;
844+ const currentSource = spanJson . data ?. [ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE ] ;
845+ if ( currentSource !== 'route' ) {
846+ // Last chance to update the transaction name with the latest route info
847+ const branches = _matchRoutes ( allRoutes || routes , location , basename ) as unknown as RouteMatch [ ] ;
848+
849+ if ( branches ) {
850+ const [ name , source ] = resolveRouteNameAndSource (
851+ location ,
852+ routes ,
853+ allRoutes || routes ,
854+ branches ,
855+ basename ,
856+ ) ;
857+
858+ // Only update if we have a valid name and the span hasn't finished
859+ if ( name && ! spanJson . timestamp ) {
860+ span . updateName ( name ) ;
861+ span . setAttribute ( SEMANTIC_ATTRIBUTE_SENTRY_SOURCE , source ) ;
862+ }
863+ }
864+ }
865+
866+ return originalEnd ( ...args ) ;
867+ } ;
868+
869+ // Mark this span as having its end() method patched to prevent duplicate patching
870+ addNonEnumerableProperty (
871+ span as { __sentry_navigation_end_patched__ ?: boolean } ,
872+ '__sentry_navigation_end_patched__' ,
873+ true ,
874+ ) ;
751875}
752876
753877// eslint-disable-next-line @typescript-eslint/no-explicit-any
0 commit comments