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