@sentry/react 10.35.0 → 10.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,6 +35,9 @@ const allRoutes = new Set();
35
35
  // Tracks lazy route loads to wait before finalizing span names
36
36
  const pendingLazyRouteLoads = new WeakMap();
37
37
 
38
+ // Tracks deferred lazy route promises that can be resolved when patchRoutesOnNavigation is called
39
+ const deferredLazyRouteResolvers = new WeakMap();
40
+
38
41
  /**
39
42
  * Schedules a callback using requestAnimationFrame when available (browser),
40
43
  * or falls back to setTimeout for SSR environments (Node.js, createMemoryRouter tests).
@@ -160,6 +163,34 @@ function trackLazyRouteLoad(span, promise) {
160
163
  });
161
164
  }
162
165
 
166
+ /**
167
+ * Creates a deferred promise for a span that will be resolved when patchRoutesOnNavigation is called.
168
+ * This ensures that patchedEnd waits for patchRoutesOnNavigation to be called before ending the span.
169
+ */
170
+ function createDeferredLazyRoutePromise(span) {
171
+ const deferredPromise = new Promise(resolve => {
172
+ deferredLazyRouteResolvers.set(span, resolve);
173
+ });
174
+
175
+ trackLazyRouteLoad(span, deferredPromise);
176
+ }
177
+
178
+ /**
179
+ * Resolves the deferred lazy route promise for a span.
180
+ * Called when patchRoutesOnNavigation is invoked.
181
+ */
182
+ function resolveDeferredLazyRoutePromise(span) {
183
+ const resolver = deferredLazyRouteResolvers.get(span);
184
+ if (resolver) {
185
+ resolver();
186
+ deferredLazyRouteResolvers.delete(span);
187
+ // Clear the flag so patchSpanEnd doesn't wait unnecessarily for routes that have already loaded
188
+ if ((span ).__sentry_may_have_lazy_routes__) {
189
+ (span ).__sentry_may_have_lazy_routes__ = false;
190
+ }
191
+ }
192
+ }
193
+
163
194
  /**
164
195
  * Processes resolved routes by adding them to allRoutes and checking for nested async handlers.
165
196
  * When capturedSpan is provided, updates that specific span instead of the current active span.
@@ -380,10 +411,30 @@ function createV6CompatibleWrapCreateBrowserRouter
380
411
  }
381
412
  }
382
413
 
383
- const wrappedOpts = wrapPatchRoutesOnNavigation(opts);
414
+ // Capture the active span BEFORE creating the router.
415
+ // This is important because the span might end (due to idle timeout) before
416
+ // patchRoutesOnNavigation is called by React Router.
417
+ const activeRootSpan = utils.getActiveRootSpan();
418
+
419
+ // If patchRoutesOnNavigation is provided and we have an active span,
420
+ // mark the span as having potential lazy routes and create a deferred promise.
421
+ const hasPatchRoutesOnNavigation =
422
+ opts && 'patchRoutesOnNavigation' in opts && typeof opts.patchRoutesOnNavigation === 'function';
423
+ if (hasPatchRoutesOnNavigation && activeRootSpan) {
424
+ // Mark the span as potentially having lazy routes
425
+ core.addNonEnumerableProperty(
426
+ activeRootSpan ,
427
+ '__sentry_may_have_lazy_routes__',
428
+ true,
429
+ );
430
+ createDeferredLazyRoutePromise(activeRootSpan);
431
+ }
432
+
433
+ // Pass the captured span to wrapPatchRoutesOnNavigation so it uses the same span
434
+ // even if the span has ended by the time patchRoutesOnNavigation is called.
435
+ const wrappedOpts = wrapPatchRoutesOnNavigation(opts, false, activeRootSpan);
384
436
  const router = createRouterFunction(routes, wrappedOpts);
385
437
  const basename = opts?.basename;
386
- const activeRootSpan = utils.getActiveRootSpan();
387
438
 
388
439
  if (router.state.historyAction === 'POP' && activeRootSpan) {
389
440
  updatePageloadTransaction({
@@ -433,7 +484,23 @@ function createV6CompatibleWrapCreateMemoryRouter
433
484
  }
434
485
  }
435
486
 
436
- const wrappedOpts = wrapPatchRoutesOnNavigation(opts, true);
487
+ // Capture the active span BEFORE creating the router (same as browser router)
488
+ const memoryActiveRootSpanEarly = utils.getActiveRootSpan();
489
+
490
+ // If patchRoutesOnNavigation is provided and we have an active span,
491
+ // mark the span as having potential lazy routes and create a deferred promise.
492
+ const hasPatchRoutesOnNavigation =
493
+ opts && 'patchRoutesOnNavigation' in opts && typeof opts.patchRoutesOnNavigation === 'function';
494
+ if (hasPatchRoutesOnNavigation && memoryActiveRootSpanEarly) {
495
+ core.addNonEnumerableProperty(
496
+ memoryActiveRootSpanEarly ,
497
+ '__sentry_may_have_lazy_routes__',
498
+ true,
499
+ );
500
+ createDeferredLazyRoutePromise(memoryActiveRootSpanEarly);
501
+ }
502
+
503
+ const wrappedOpts = wrapPatchRoutesOnNavigation(opts, true, memoryActiveRootSpanEarly);
437
504
 
438
505
  const router = createRouterFunction(routes, wrappedOpts);
439
506
  const basename = opts?.basename;
@@ -627,9 +694,36 @@ function createV6CompatibleWrapUseRoutes(origUseRoutes, version) {
627
694
  };
628
695
  }
629
696
 
697
+ /**
698
+ * Helper to update the current span (navigation or pageload) with lazy-loaded route information.
699
+ * Reduces code duplication in patchRoutesOnNavigation wrapper.
700
+ */
701
+ function updateSpanWithLazyRoutes(pathname, forceUpdate) {
702
+ const currentActiveRootSpan = utils.getActiveRootSpan();
703
+ if (!currentActiveRootSpan) {
704
+ return;
705
+ }
706
+
707
+ const spanOp = (core.spanToJSON(currentActiveRootSpan) ).op;
708
+ const location = { pathname, search: '', hash: '', state: null, key: 'default' };
709
+ const routesArray = Array.from(allRoutes);
710
+
711
+ if (spanOp === 'navigation') {
712
+ updateNavigationSpan(currentActiveRootSpan, location, routesArray, forceUpdate, _matchRoutes);
713
+ } else if (spanOp === 'pageload') {
714
+ updatePageloadTransaction({
715
+ activeRootSpan: currentActiveRootSpan,
716
+ location,
717
+ routes: routesArray,
718
+ allRoutes: routesArray,
719
+ });
720
+ }
721
+ }
722
+
630
723
  function wrapPatchRoutesOnNavigation(
631
724
  opts,
632
725
  isMemoryRouter = false,
726
+ capturedSpan,
633
727
  ) {
634
728
  if (!opts || !('patchRoutesOnNavigation' in opts) || typeof opts.patchRoutesOnNavigation !== 'function') {
635
729
  return opts || {};
@@ -642,29 +736,47 @@ function wrapPatchRoutesOnNavigation(
642
736
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
643
737
  const targetPath = (args )?.path;
644
738
 
645
- const activeRootSpan = utils.getActiveRootSpan();
739
+ // Use current active span if available, otherwise fall back to captured span (from router creation time).
740
+ // This ensures navigation spans use their own span (not the stale pageload span), while still
741
+ // supporting pageload spans that may have ended before patchRoutesOnNavigation is called.
742
+ const activeRootSpan = utils.getActiveRootSpan() ?? capturedSpan;
646
743
 
647
744
  if (!isMemoryRouter) {
648
745
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
649
746
  const originalPatch = (args )?.patch;
747
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
748
+ const matches = (args )?.matches ;
650
749
  if (originalPatch) {
651
750
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
652
751
  (args ).patch = (routeId, children) => {
653
752
  addRoutesToAllRoutes(children);
654
- const currentActiveRootSpan = utils.getActiveRootSpan();
753
+
754
+ // Find the parent route from matches and attach children to it in allRoutes.
755
+ // React Router's patch attaches children to its internal route copies, but we need
756
+ // to update the route objects in our allRoutes Set for proper route matching.
757
+ if (matches && matches.length > 0) {
758
+ const leafMatch = matches[matches.length - 1];
759
+ const leafRoute = leafMatch?.route;
760
+ if (leafRoute) {
761
+ // Find the matching route in allRoutes by id, reference, or path
762
+ const matchingRoute = Array.from(allRoutes).find(route => {
763
+ const idMatches = route.id !== undefined && route.id === routeId;
764
+ const referenceMatches = route === leafRoute;
765
+ const pathMatches =
766
+ route.path !== undefined && leafRoute.path !== undefined && route.path === leafRoute.path;
767
+
768
+ return idMatches || referenceMatches || pathMatches;
769
+ });
770
+
771
+ if (matchingRoute) {
772
+ addResolvedRoutesToParent(children, matchingRoute);
773
+ }
774
+ }
775
+ }
776
+
655
777
  // Only update if we have a valid targetPath (patchRoutesOnNavigation can be called without path)
656
- if (
657
- targetPath &&
658
- currentActiveRootSpan &&
659
- (core.spanToJSON(currentActiveRootSpan) ).op === 'navigation'
660
- ) {
661
- updateNavigationSpan(
662
- currentActiveRootSpan,
663
- { pathname: targetPath, search: '', hash: '', state: null, key: 'default' },
664
- Array.from(allRoutes),
665
- true,
666
- _matchRoutes,
667
- );
778
+ if (targetPath) {
779
+ updateSpanWithLazyRoutes(targetPath, true);
668
780
  }
669
781
  return originalPatch(routeId, children);
670
782
  };
@@ -679,21 +791,16 @@ function wrapPatchRoutesOnNavigation(
679
791
  result = await originalPatchRoutes(args);
680
792
  } finally {
681
793
  utils.clearNavigationContext(contextToken);
794
+ // Resolve the deferred promise now that patchRoutesOnNavigation has completed.
795
+ // This ensures patchedEnd has waited long enough for the lazy routes to load.
796
+ if (activeRootSpan) {
797
+ resolveDeferredLazyRoutePromise(activeRootSpan);
798
+ }
682
799
  }
683
800
 
684
- const currentActiveRootSpan = utils.getActiveRootSpan();
685
- if (currentActiveRootSpan && (core.spanToJSON(currentActiveRootSpan) ).op === 'navigation') {
686
- const pathname = isMemoryRouter ? targetPath : targetPath || browser.WINDOW.location?.pathname;
687
-
688
- if (pathname) {
689
- updateNavigationSpan(
690
- currentActiveRootSpan,
691
- { pathname, search: '', hash: '', state: null, key: 'default' },
692
- Array.from(allRoutes),
693
- false,
694
- _matchRoutes,
695
- );
696
- }
801
+ const pathname = isMemoryRouter ? targetPath : targetPath || browser.WINDOW.location?.pathname;
802
+ if (pathname) {
803
+ updateSpanWithLazyRoutes(pathname, false);
697
804
  }
698
805
 
699
806
  return result;
@@ -808,7 +915,7 @@ function handleNavigation(opts
808
915
  pathname: location.pathname,
809
916
  locationKey,
810
917
  });
811
- patchSpanEnd(navigationSpan, location, routes, basename, allRoutes, 'navigation');
918
+ patchSpanEnd(navigationSpan, location, routes, basename, 'navigation');
812
919
  } else {
813
920
  // If no span was created, remove the placeholder
814
921
  activeNavigationSpans.delete(client);
@@ -875,8 +982,13 @@ function updatePageloadTransaction({
875
982
  activeRootSpan.setAttribute(core.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
876
983
 
877
984
  // Patch span.end() to ensure we update the name one last time before the span is sent
878
- patchSpanEnd(activeRootSpan, location, routes, basename, allRoutes, 'pageload');
985
+ patchSpanEnd(activeRootSpan, location, routes, basename, 'pageload');
879
986
  }
987
+ } else if (activeRootSpan) {
988
+ // Even if branches is null (can happen when lazy routes haven't loaded yet),
989
+ // we still need to patch span.end() so that when lazy routes load and the span ends,
990
+ // we can update the transaction name correctly.
991
+ patchSpanEnd(activeRootSpan, location, routes, basename, 'pageload');
880
992
  }
881
993
  }
882
994
 
@@ -971,7 +1083,6 @@ function patchSpanEnd(
971
1083
  location,
972
1084
  routes,
973
1085
  basename,
974
- _allRoutes,
975
1086
  spanType,
976
1087
  ) {
977
1088
  const patchedPropertyName = `__sentry_${spanType}_end_patched__` ;
@@ -981,8 +1092,7 @@ function patchSpanEnd(
981
1092
  return;
982
1093
  }
983
1094
 
984
- // Use the passed route context, or fall back to global Set
985
- const allRoutesSet = _allRoutes ? new Set(_allRoutes) : allRoutes;
1095
+ // Uses global allRoutes to access lazy-loaded routes added after this function was called.
986
1096
 
987
1097
  const originalEnd = span.end.bind(span);
988
1098
  let endCalled = false;
@@ -1013,29 +1123,40 @@ function patchSpanEnd(
1013
1123
  };
1014
1124
 
1015
1125
  const pendingPromises = pendingLazyRouteLoads.get(span);
1126
+ const mayHaveLazyRoutes = (span ).__sentry_may_have_lazy_routes__;
1127
+
1016
1128
  // Wait for lazy routes if:
1017
- // 1. There are pending promises AND
1129
+ // 1. (There are pending promises OR the span was marked as potentially having lazy routes) AND
1018
1130
  // 2. Current name exists AND
1019
1131
  // 3. Either the name has a wildcard OR the source is not 'route' (URL-based names)
1132
+ const hasPendingOrMayHaveLazyRoutes = (pendingPromises && pendingPromises.size > 0) || mayHaveLazyRoutes;
1020
1133
  const shouldWaitForLazyRoutes =
1021
- pendingPromises &&
1022
- pendingPromises.size > 0 &&
1134
+ hasPendingOrMayHaveLazyRoutes &&
1023
1135
  currentName &&
1024
1136
  (utils.transactionNameHasWildcard(currentName) || currentSource !== 'route');
1025
1137
 
1026
1138
  if (shouldWaitForLazyRoutes) {
1027
1139
  if (_lazyRouteTimeout === 0) {
1028
- tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutesSet);
1140
+ tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutes);
1029
1141
  cleanupNavigationSpan();
1030
1142
  originalEnd(endTimestamp);
1031
1143
  return;
1032
1144
  }
1033
1145
 
1034
- const allSettled = Promise.allSettled(pendingPromises).then(() => {});
1035
- const waitPromise =
1036
- _lazyRouteTimeout === Infinity
1037
- ? allSettled
1038
- : Promise.race([allSettled, new Promise(r => setTimeout(r, _lazyRouteTimeout))]);
1146
+ // If we have pending promises, wait for them. Otherwise, just wait for the timeout.
1147
+ // This handles the case where we know lazy routes might load but patchRoutesOnNavigation
1148
+ // hasn't been called yet.
1149
+ const timeoutPromise = new Promise(r => setTimeout(r, _lazyRouteTimeout));
1150
+ let waitPromise;
1151
+
1152
+ if (pendingPromises && pendingPromises.size > 0) {
1153
+ const allSettled = Promise.allSettled(pendingPromises).then(() => {});
1154
+ waitPromise = _lazyRouteTimeout === Infinity ? allSettled : Promise.race([allSettled, timeoutPromise]);
1155
+ } else {
1156
+ // No pending promises yet, but we know lazy routes might load
1157
+ // Wait for the timeout to give React Router time to call patchRoutesOnNavigation
1158
+ waitPromise = timeoutPromise;
1159
+ }
1039
1160
 
1040
1161
  waitPromise
1041
1162
  .then(() => {
@@ -1048,7 +1169,7 @@ function patchSpanEnd(
1048
1169
  routes,
1049
1170
  basename,
1050
1171
  spanType,
1051
- allRoutesSet,
1172
+ allRoutes,
1052
1173
  );
1053
1174
  cleanupNavigationSpan();
1054
1175
  originalEnd(endTimestamp);
@@ -1060,7 +1181,7 @@ function patchSpanEnd(
1060
1181
  return;
1061
1182
  }
1062
1183
 
1063
- tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutesSet);
1184
+ tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutes);
1064
1185
  cleanupNavigationSpan();
1065
1186
  originalEnd(endTimestamp);
1066
1187
  };