@sentry/react 10.25.0 → 10.27.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.
@@ -18,14 +18,112 @@ let _useLocation;
18
18
  let _useNavigationType;
19
19
  let _createRoutesFromChildren;
20
20
  let _matchRoutes;
21
+
21
22
  let _enableAsyncRouteHandlers = false;
23
+ let _lazyRouteTimeout = 3000;
22
24
 
23
25
  const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet();
24
26
 
27
+ // Prevents duplicate spans when router.subscribe fires multiple times
28
+ const activeNavigationSpans = new WeakMap
29
+
30
+ ();
31
+
32
+ // Exported for testing only
33
+ const allRoutes = new Set();
34
+
35
+ // Tracks lazy route loads to wait before finalizing span names
36
+ const pendingLazyRouteLoads = new WeakMap();
37
+
25
38
  /**
26
- * Adds resolved routes as children to the parent route.
27
- * Prevents duplicate routes by checking if they already exist.
39
+ * Schedules a callback using requestAnimationFrame when available (browser),
40
+ * or falls back to setTimeout for SSR environments (Node.js, createMemoryRouter tests).
28
41
  */
42
+ function scheduleCallback(callback) {
43
+ if (browser.WINDOW?.requestAnimationFrame) {
44
+ return browser.WINDOW.requestAnimationFrame(callback);
45
+ }
46
+ return setTimeout(callback, 0) ;
47
+ }
48
+
49
+ /**
50
+ * Cancels a scheduled callback, handling both RAF (browser) and timeout (SSR) IDs.
51
+ */
52
+ function cancelScheduledCallback(id) {
53
+ if (browser.WINDOW?.cancelAnimationFrame) {
54
+ browser.WINDOW.cancelAnimationFrame(id);
55
+ } else {
56
+ clearTimeout(id);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Computes location key for duplicate detection. Normalizes undefined/null to empty strings.
62
+ * Exported for testing.
63
+ */
64
+ function computeLocationKey(location) {
65
+ return `${location.pathname}${location.search || ''}${location.hash || ''}`;
66
+ }
67
+
68
+ /**
69
+ * Checks if a route name is parameterized (contains route parameters like :id or wildcards like *)
70
+ * vs a raw URL path.
71
+ */
72
+ function isParameterizedRoute(routeName) {
73
+ return routeName.includes(':') || routeName.includes('*');
74
+ }
75
+
76
+ /**
77
+ * Determines if a navigation should be skipped as a duplicate, and if an existing span should be updated.
78
+ * Exported for testing.
79
+ *
80
+ * @returns An object with:
81
+ * - skip: boolean - Whether to skip creating a new span
82
+ * - shouldUpdate: boolean - Whether to update the existing span name (wildcard upgrade)
83
+ */
84
+ function shouldSkipNavigation(
85
+ trackedNav
86
+
87
+ ,
88
+ locationKey,
89
+ proposedName,
90
+ spanHasEnded,
91
+ ) {
92
+ if (!trackedNav) {
93
+ return { skip: false, shouldUpdate: false };
94
+ }
95
+
96
+ // Check if this is a duplicate navigation (same location)
97
+ // 1. If it's a placeholder, it's always a duplicate (we're waiting for the real one)
98
+ // 2. If it's a real span, it's a duplicate only if it hasn't ended yet
99
+ const isDuplicate = trackedNav.locationKey === locationKey && (trackedNav.isPlaceholder || !spanHasEnded);
100
+
101
+ if (isDuplicate) {
102
+ // Check if we should update the span name with a better route
103
+ // Allow updates if:
104
+ // 1. Current has wildcard and new doesn't (wildcard → parameterized upgrade)
105
+ // 2. Current is raw path and new is parameterized (raw → parameterized upgrade)
106
+ // 3. New name is different and more specific (longer, indicating nested routes resolved)
107
+ const currentHasWildcard = !!trackedNav.routeName && utils.transactionNameHasWildcard(trackedNav.routeName);
108
+ const proposedHasWildcard = utils.transactionNameHasWildcard(proposedName);
109
+ const currentIsParameterized = !!trackedNav.routeName && isParameterizedRoute(trackedNav.routeName);
110
+ const proposedIsParameterized = isParameterizedRoute(proposedName);
111
+
112
+ const isWildcardUpgrade = currentHasWildcard && !proposedHasWildcard;
113
+ const isRawToParameterized = !currentIsParameterized && proposedIsParameterized;
114
+ const isMoreSpecific =
115
+ proposedName !== trackedNav.routeName &&
116
+ proposedName.length > (trackedNav.routeName?.length || 0) &&
117
+ !proposedHasWildcard;
118
+
119
+ const shouldUpdate = !!(trackedNav.routeName && (isWildcardUpgrade || isRawToParameterized || isMoreSpecific));
120
+
121
+ return { skip: true, shouldUpdate };
122
+ }
123
+
124
+ return { skip: false, shouldUpdate: false };
125
+ }
126
+
29
127
  function addResolvedRoutesToParent(resolvedRoutes, parentRoute) {
30
128
  const existingChildren = parentRoute.children || [];
31
129
 
@@ -44,9 +142,23 @@ function addResolvedRoutesToParent(resolvedRoutes, parentRoute) {
44
142
  }
45
143
  }
46
144
 
47
- // Keeping as a global variable for cross-usage in multiple functions
48
- // only exported for testing purposes
49
- const allRoutes = new Set();
145
+ /** Registers a pending lazy route load promise for a span. */
146
+ function trackLazyRouteLoad(span, promise) {
147
+ let promises = pendingLazyRouteLoads.get(span);
148
+ if (!promises) {
149
+ promises = new Set();
150
+ pendingLazyRouteLoads.set(span, promises);
151
+ }
152
+ promises.add(promise);
153
+
154
+ // Clean up when promise resolves/rejects
155
+ promise.finally(() => {
156
+ const currentPromises = pendingLazyRouteLoads.get(span);
157
+ if (currentPromises) {
158
+ currentPromises.delete(promise);
159
+ }
160
+ });
161
+ }
50
162
 
51
163
  /**
52
164
  * Processes resolved routes by adding them to allRoutes and checking for nested async handlers.
@@ -112,13 +224,14 @@ function updateNavigationSpan(
112
224
  forceUpdate = false,
113
225
  matchRoutes,
114
226
  ) {
115
- // Check if this span has already been named to avoid multiple updates
116
- // But allow updates if this is a forced update (e.g., when lazy routes are loaded)
117
- const hasBeenNamed =
118
- !forceUpdate && (activeRootSpan )?.__sentry_navigation_name_set__;
227
+ const spanJson = core.spanToJSON(activeRootSpan);
228
+ const currentName = spanJson.description;
229
+
230
+ const hasBeenNamed = (activeRootSpan )?.__sentry_navigation_name_set__;
231
+ const currentNameHasWildcard = currentName && utils.transactionNameHasWildcard(currentName);
232
+ const shouldUpdate = !hasBeenNamed || forceUpdate || currentNameHasWildcard;
119
233
 
120
- if (!hasBeenNamed) {
121
- // Get fresh branches for the current location with all loaded routes
234
+ if (shouldUpdate && !spanJson.timestamp) {
122
235
  const currentBranches = matchRoutes(allRoutes, location);
123
236
  const [name, source] = utils.resolveRouteNameAndSource(
124
237
  location,
@@ -128,22 +241,105 @@ function updateNavigationSpan(
128
241
  '',
129
242
  );
130
243
 
131
- // Only update if we have a valid name and the span hasn't finished
132
- const spanJson = core.spanToJSON(activeRootSpan);
133
- if (name && !spanJson.timestamp) {
244
+ const currentSource = spanJson.data?.[core.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
245
+ const isImprovement =
246
+ name &&
247
+ (!currentName || // No current name - always set
248
+ (!hasBeenNamed && (currentSource !== 'route' || source === 'route')) || // Not finalized - allow unless downgrading route→url
249
+ (currentSource !== 'route' && source === 'route') || // URL → route upgrade
250
+ (currentSource === 'route' && source === 'route' && currentNameHasWildcard)); // Route → better route (only if current has wildcard)
251
+ if (isImprovement) {
134
252
  activeRootSpan.updateName(name);
135
253
  activeRootSpan.setAttribute(core.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
136
254
 
137
- // Mark this span as having its name set to prevent future updates
138
- core.addNonEnumerableProperty(
139
- activeRootSpan ,
140
- '__sentry_navigation_name_set__',
141
- true,
142
- );
255
+ // Only mark as finalized for non-wildcard route names (allows URL→route upgrades).
256
+ if (!utils.transactionNameHasWildcard(name) && source === 'route') {
257
+ core.addNonEnumerableProperty(
258
+ activeRootSpan ,
259
+ '__sentry_navigation_name_set__',
260
+ true,
261
+ );
262
+ }
143
263
  }
144
264
  }
145
265
  }
146
266
 
267
+ function setupRouterSubscription(
268
+ router,
269
+ routes,
270
+ version,
271
+ basename,
272
+ activeRootSpan,
273
+ ) {
274
+ let isInitialPageloadComplete = false;
275
+ let hasSeenPageloadSpan = !!activeRootSpan && core.spanToJSON(activeRootSpan).op === 'pageload';
276
+ let hasSeenPopAfterPageload = false;
277
+ let scheduledNavigationHandler = null;
278
+ let lastHandledPathname = null;
279
+
280
+ router.subscribe((state) => {
281
+ if (!isInitialPageloadComplete) {
282
+ const currentRootSpan = getActiveRootSpan();
283
+ const isCurrentlyInPageload = currentRootSpan && core.spanToJSON(currentRootSpan).op === 'pageload';
284
+
285
+ if (isCurrentlyInPageload) {
286
+ hasSeenPageloadSpan = true;
287
+ } else if (hasSeenPageloadSpan) {
288
+ if (state.historyAction === 'POP' && !hasSeenPopAfterPageload) {
289
+ hasSeenPopAfterPageload = true;
290
+ } else {
291
+ isInitialPageloadComplete = true;
292
+ }
293
+ }
294
+ }
295
+
296
+ const shouldHandleNavigation =
297
+ state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete);
298
+
299
+ if (shouldHandleNavigation) {
300
+ // Include search and hash to allow query/hash-only navigations
301
+ // Use computeLocationKey() to ensure undefined/null values are normalized to empty strings
302
+ const currentLocationKey = computeLocationKey(state.location);
303
+ const navigationHandler = () => {
304
+ // Prevent multiple calls for the same location within the same navigation cycle
305
+ if (lastHandledPathname === currentLocationKey) {
306
+ return;
307
+ }
308
+ lastHandledPathname = currentLocationKey;
309
+ scheduledNavigationHandler = null;
310
+ handleNavigation({
311
+ location: state.location,
312
+ routes,
313
+ navigationType: state.historyAction,
314
+ version,
315
+ basename,
316
+ allRoutes: Array.from(allRoutes),
317
+ });
318
+ };
319
+
320
+ if (state.navigation.state !== 'idle') {
321
+ // Navigation in progress - reset if location changed
322
+ if (lastHandledPathname !== currentLocationKey) {
323
+ lastHandledPathname = null;
324
+ }
325
+ // Cancel any previously scheduled handler to avoid duplicates
326
+ if (scheduledNavigationHandler !== null) {
327
+ cancelScheduledCallback(scheduledNavigationHandler);
328
+ }
329
+ scheduledNavigationHandler = scheduleCallback(navigationHandler);
330
+ } else {
331
+ // Navigation completed - cancel scheduled handler if any, then call immediately
332
+ if (scheduledNavigationHandler !== null) {
333
+ cancelScheduledCallback(scheduledNavigationHandler);
334
+ scheduledNavigationHandler = null;
335
+ }
336
+ navigationHandler();
337
+ // Don't reset - next navigation cycle resets to prevent duplicates within same cycle.
338
+ }
339
+ }
340
+ });
341
+ }
342
+
147
343
  /**
148
344
  * Creates a wrapCreateBrowserRouter function that can be used with all React Router v6 compatible versions.
149
345
  */
@@ -165,30 +361,17 @@ function createV6CompatibleWrapCreateBrowserRouter
165
361
  return function (routes, opts) {
166
362
  addRoutesToAllRoutes(routes);
167
363
 
168
- // Check for async handlers that might contain sub-route declarations (only if enabled)
169
364
  if (_enableAsyncRouteHandlers) {
170
365
  for (const route of routes) {
171
366
  lazyRoutes.checkRouteForAsyncHandler(route, processResolvedRoutes);
172
367
  }
173
368
  }
174
369
 
175
- // Wrap patchRoutesOnNavigation to detect when lazy routes are loaded
176
370
  const wrappedOpts = wrapPatchRoutesOnNavigation(opts);
177
-
178
371
  const router = createRouterFunction(routes, wrappedOpts);
179
372
  const basename = opts?.basename;
180
-
181
373
  const activeRootSpan = getActiveRootSpan();
182
374
 
183
- // Track whether we've completed the initial pageload to properly distinguish
184
- // between POPs that occur during pageload vs. legitimate back/forward navigation.
185
- let isInitialPageloadComplete = false;
186
- let hasSeenPageloadSpan = !!activeRootSpan && core.spanToJSON(activeRootSpan).op === 'pageload';
187
- let hasSeenPopAfterPageload = false;
188
-
189
- // The initial load ends when `createBrowserRouter` is called.
190
- // This is the earliest convenient time to update the transaction name.
191
- // Callbacks to `router.subscribe` are not called for the initial load.
192
375
  if (router.state.historyAction === 'POP' && activeRootSpan) {
193
376
  updatePageloadTransaction({
194
377
  activeRootSpan,
@@ -199,50 +382,7 @@ function createV6CompatibleWrapCreateBrowserRouter
199
382
  });
200
383
  }
201
384
 
202
- router.subscribe((state) => {
203
- // Track pageload completion to distinguish POPs during pageload from legitimate back/forward navigation
204
- if (!isInitialPageloadComplete) {
205
- const currentRootSpan = getActiveRootSpan();
206
- const isCurrentlyInPageload = currentRootSpan && core.spanToJSON(currentRootSpan).op === 'pageload';
207
-
208
- if (isCurrentlyInPageload) {
209
- hasSeenPageloadSpan = true;
210
- } else if (hasSeenPageloadSpan) {
211
- // Pageload span was active but is now gone - pageload has completed
212
- if (state.historyAction === 'POP' && !hasSeenPopAfterPageload) {
213
- // Pageload ended: ignore the first POP after pageload
214
- hasSeenPopAfterPageload = true;
215
- } else {
216
- // Pageload ended: either non-POP action or subsequent POP
217
- isInitialPageloadComplete = true;
218
- }
219
- }
220
- // If we haven't seen a pageload span yet, keep waiting (don't mark as complete)
221
- }
222
-
223
- const shouldHandleNavigation =
224
- state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete);
225
-
226
- if (shouldHandleNavigation) {
227
- const navigationHandler = () => {
228
- handleNavigation({
229
- location: state.location,
230
- routes,
231
- navigationType: state.historyAction,
232
- version,
233
- basename,
234
- allRoutes: Array.from(allRoutes),
235
- });
236
- };
237
-
238
- // Wait for the next render if loading an unsettled route
239
- if (state.navigation.state !== 'idle') {
240
- requestAnimationFrame(navigationHandler);
241
- } else {
242
- navigationHandler();
243
- }
244
- }
245
- });
385
+ setupRouterSubscription(router, routes, version, basename, activeRootSpan);
246
386
 
247
387
  return router;
248
388
  };
@@ -274,14 +414,12 @@ function createV6CompatibleWrapCreateMemoryRouter
274
414
  ) {
275
415
  addRoutesToAllRoutes(routes);
276
416
 
277
- // Check for async handlers that might contain sub-route declarations (only if enabled)
278
417
  if (_enableAsyncRouteHandlers) {
279
418
  for (const route of routes) {
280
419
  lazyRoutes.checkRouteForAsyncHandler(route, processResolvedRoutes);
281
420
  }
282
421
  }
283
422
 
284
- // Wrap patchRoutesOnNavigation to detect when lazy routes are loaded
285
423
  const wrappedOpts = wrapPatchRoutesOnNavigation(opts, true);
286
424
 
287
425
  const router = createRouterFunction(routes, wrappedOpts);
@@ -319,58 +457,7 @@ function createV6CompatibleWrapCreateMemoryRouter
319
457
  });
320
458
  }
321
459
 
322
- // Track whether we've completed the initial pageload to properly distinguish
323
- // between POPs that occur during pageload vs. legitimate back/forward navigation.
324
- let isInitialPageloadComplete = false;
325
- let hasSeenPageloadSpan = !!memoryActiveRootSpan && core.spanToJSON(memoryActiveRootSpan).op === 'pageload';
326
- let hasSeenPopAfterPageload = false;
327
-
328
- router.subscribe((state) => {
329
- // Track pageload completion to distinguish POPs during pageload from legitimate back/forward navigation
330
- if (!isInitialPageloadComplete) {
331
- const currentRootSpan = getActiveRootSpan();
332
- const isCurrentlyInPageload = currentRootSpan && core.spanToJSON(currentRootSpan).op === 'pageload';
333
-
334
- if (isCurrentlyInPageload) {
335
- hasSeenPageloadSpan = true;
336
- } else if (hasSeenPageloadSpan) {
337
- // Pageload span was active but is now gone - pageload has completed
338
- if (state.historyAction === 'POP' && !hasSeenPopAfterPageload) {
339
- // Pageload ended: ignore the first POP after pageload
340
- hasSeenPopAfterPageload = true;
341
- } else {
342
- // Pageload ended: either non-POP action or subsequent POP
343
- isInitialPageloadComplete = true;
344
- }
345
- }
346
- // If we haven't seen a pageload span yet, keep waiting (don't mark as complete)
347
- }
348
-
349
- const location = state.location;
350
-
351
- const shouldHandleNavigation =
352
- state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete);
353
-
354
- if (shouldHandleNavigation) {
355
- const navigationHandler = () => {
356
- handleNavigation({
357
- location,
358
- routes,
359
- navigationType: state.historyAction,
360
- version,
361
- basename,
362
- allRoutes: Array.from(allRoutes),
363
- });
364
- };
365
-
366
- // Wait for the next render if loading an unsettled route
367
- if (state.navigation.state !== 'idle') {
368
- requestAnimationFrame(navigationHandler);
369
- } else {
370
- navigationHandler();
371
- }
372
- }
373
- });
460
+ setupRouterSubscription(router, routes, version, basename, memoryActiveRootSpan);
374
461
 
375
462
  return router;
376
463
  };
@@ -395,6 +482,7 @@ function createReactRouterV6CompatibleTracingIntegration(
395
482
  enableAsyncRouteHandlers = false,
396
483
  instrumentPageLoad = true,
397
484
  instrumentNavigation = true,
485
+ lazyRouteTimeout,
398
486
  } = options;
399
487
 
400
488
  return {
@@ -402,6 +490,36 @@ function createReactRouterV6CompatibleTracingIntegration(
402
490
  setup(client) {
403
491
  integration.setup(client);
404
492
 
493
+ const finalTimeout = options.finalTimeout ?? 30000;
494
+ const defaultMaxWait = (options.idleTimeout ?? 1000) * 3;
495
+ const configuredMaxWait = lazyRouteTimeout ?? defaultMaxWait;
496
+
497
+ // Cap Infinity at finalTimeout to prevent indefinite hangs
498
+ if (configuredMaxWait === Infinity) {
499
+ _lazyRouteTimeout = finalTimeout;
500
+ debugBuild.DEBUG_BUILD &&
501
+ core.debug.log(
502
+ '[React Router] lazyRouteTimeout set to Infinity, capping at finalTimeout:',
503
+ finalTimeout,
504
+ 'ms to prevent indefinite hangs',
505
+ );
506
+ } else if (Number.isNaN(configuredMaxWait)) {
507
+ debugBuild.DEBUG_BUILD &&
508
+ core.debug.warn('[React Router] lazyRouteTimeout must be a number, falling back to default:', defaultMaxWait);
509
+ _lazyRouteTimeout = defaultMaxWait;
510
+ } else if (configuredMaxWait < 0) {
511
+ debugBuild.DEBUG_BUILD &&
512
+ core.debug.warn(
513
+ '[React Router] lazyRouteTimeout must be non-negative or Infinity, got:',
514
+ configuredMaxWait,
515
+ 'falling back to:',
516
+ defaultMaxWait,
517
+ );
518
+ _lazyRouteTimeout = defaultMaxWait;
519
+ } else {
520
+ _lazyRouteTimeout = configuredMaxWait;
521
+ }
522
+
405
523
  _useEffect = useEffect;
406
524
  _useLocation = useLocation;
407
525
  _useNavigationType = useNavigationType;
@@ -474,6 +592,9 @@ function createV6CompatibleWrapUseRoutes(origUseRoutes, version) {
474
592
  });
475
593
  isMountRenderPass.current = false;
476
594
  } else {
595
+ // Note: Component-based routes don't support lazy route tracking via lazyRouteTimeout
596
+ // because React.lazy() loads happen at the component level, not the router level.
597
+ // Use createBrowserRouter with patchRoutesOnNavigation for lazy route tracking.
477
598
  handleNavigation({
478
599
  location: normalizedLocation,
479
600
  routes,
@@ -508,7 +629,8 @@ function wrapPatchRoutesOnNavigation(
508
629
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
509
630
  const targetPath = (args )?.path;
510
631
 
511
- // For browser router, wrap the patch function to update span during patching
632
+ const activeRootSpan = getActiveRootSpan();
633
+
512
634
  if (!isMemoryRouter) {
513
635
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
514
636
  const originalPatch = (args )?.patch;
@@ -516,13 +638,13 @@ function wrapPatchRoutesOnNavigation(
516
638
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
517
639
  (args ).patch = (routeId, children) => {
518
640
  addRoutesToAllRoutes(children);
519
- const activeRootSpan = getActiveRootSpan();
520
- if (activeRootSpan && (core.spanToJSON(activeRootSpan) ).op === 'navigation') {
641
+ const currentActiveRootSpan = getActiveRootSpan();
642
+ if (currentActiveRootSpan && (core.spanToJSON(currentActiveRootSpan) ).op === 'navigation') {
521
643
  updateNavigationSpan(
522
- activeRootSpan,
644
+ currentActiveRootSpan,
523
645
  { pathname: targetPath, search: '', hash: '', state: null, key: 'default' },
524
646
  Array.from(allRoutes),
525
- true, // forceUpdate = true since we're loading lazy routes
647
+ true,
526
648
  _matchRoutes,
527
649
  );
528
650
  }
@@ -531,50 +653,48 @@ function wrapPatchRoutesOnNavigation(
531
653
  }
532
654
  }
533
655
 
534
- const result = await originalPatchRoutes(args);
535
-
536
- // Update navigation span after routes are patched
537
- const activeRootSpan = getActiveRootSpan();
538
- if (activeRootSpan && (core.spanToJSON(activeRootSpan) ).op === 'navigation') {
539
- // Determine pathname based on router type
540
- let pathname;
541
- if (isMemoryRouter) {
542
- // For memory routers, only use targetPath
543
- pathname = targetPath;
544
- } else {
545
- // For browser routers, use targetPath or fall back to window.location
546
- pathname = targetPath || browser.WINDOW.location?.pathname;
656
+ const lazyLoadPromise = (async () => {
657
+ const result = await originalPatchRoutes(args);
658
+
659
+ const currentActiveRootSpan = getActiveRootSpan();
660
+ if (currentActiveRootSpan && (core.spanToJSON(currentActiveRootSpan) ).op === 'navigation') {
661
+ const pathname = isMemoryRouter ? targetPath : targetPath || browser.WINDOW.location?.pathname;
662
+
663
+ if (pathname) {
664
+ updateNavigationSpan(
665
+ currentActiveRootSpan,
666
+ { pathname, search: '', hash: '', state: null, key: 'default' },
667
+ Array.from(allRoutes),
668
+ false,
669
+ _matchRoutes,
670
+ );
671
+ }
547
672
  }
548
673
 
549
- if (pathname) {
550
- updateNavigationSpan(
551
- activeRootSpan,
552
- { pathname, search: '', hash: '', state: null, key: 'default' },
553
- Array.from(allRoutes),
554
- false, // forceUpdate = false since this is after lazy routes are loaded
555
- _matchRoutes,
556
- );
557
- }
674
+ return result;
675
+ })();
676
+
677
+ if (activeRootSpan) {
678
+ trackLazyRouteLoad(activeRootSpan, lazyLoadPromise);
558
679
  }
559
680
 
560
- return result;
681
+ return lazyLoadPromise;
561
682
  },
562
683
  };
563
684
  }
564
685
 
686
+ // eslint-disable-next-line complexity
565
687
  function handleNavigation(opts
566
688
 
567
689
  ) {
568
690
  const { location, routes, navigationType, version, matches, basename, allRoutes } = opts;
569
- const branches = Array.isArray(matches) ? matches : _matchRoutes(routes, location, basename);
691
+ const branches = Array.isArray(matches) ? matches : _matchRoutes(allRoutes || routes, location, basename);
570
692
 
571
693
  const client = core.getClient();
572
694
  if (!client || !CLIENTS_WITH_INSTRUMENT_NAVIGATION.has(client)) {
573
695
  return;
574
696
  }
575
697
 
576
- // Avoid starting a navigation span on initial load when a pageload root span is active.
577
- // This commonly happens when lazy routes resolve during the first render and React Router emits a POP.
578
698
  const activeRootSpan = getActiveRootSpan();
579
699
  if (activeRootSpan && core.spanToJSON(activeRootSpan).op === 'pageload' && navigationType === 'POP') {
580
700
  return;
@@ -583,31 +703,90 @@ function handleNavigation(opts
583
703
  if ((navigationType === 'PUSH' || navigationType === 'POP') && branches) {
584
704
  const [name, source] = utils.resolveRouteNameAndSource(
585
705
  location,
586
- routes,
706
+ allRoutes || routes,
587
707
  allRoutes || routes,
588
708
  branches ,
589
709
  basename,
590
710
  );
591
711
 
592
- const activeSpan = core.getActiveSpan();
593
- const spanJson = activeSpan && core.spanToJSON(activeSpan);
594
- const isAlreadyInNavigationSpan = spanJson?.op === 'navigation';
712
+ const locationKey = computeLocationKey(location);
713
+ const trackedNav = activeNavigationSpans.get(client);
714
+
715
+ // Determine if this navigation should be skipped as a duplicate
716
+ const trackedSpanHasEnded =
717
+ trackedNav && !trackedNav.isPlaceholder ? !!core.spanToJSON(trackedNav.span).timestamp : false;
718
+ const { skip, shouldUpdate } = shouldSkipNavigation(trackedNav, locationKey, name, trackedSpanHasEnded);
719
+
720
+ if (skip) {
721
+ if (shouldUpdate && trackedNav) {
722
+ const oldName = trackedNav.routeName;
723
+
724
+ if (trackedNav.isPlaceholder) {
725
+ // Update placeholder's route name - the real span will be created with this name
726
+ trackedNav.routeName = name;
727
+ debugBuild.DEBUG_BUILD &&
728
+ core.debug.log(
729
+ `[Tracing] Updated placeholder navigation name from "${oldName}" to "${name}" (will apply to real span)`,
730
+ );
731
+ } else {
732
+ // Update existing real span from wildcard to parameterized route name
733
+ trackedNav.span.updateName(name);
734
+ trackedNav.span.setAttribute(core.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source );
735
+ core.addNonEnumerableProperty(
736
+ trackedNav.span ,
737
+ '__sentry_navigation_name_set__',
738
+ true,
739
+ );
740
+ trackedNav.routeName = name;
741
+ debugBuild.DEBUG_BUILD && core.debug.log(`[Tracing] Updated navigation span name from "${oldName}" to "${name}"`);
742
+ }
743
+ } else {
744
+ debugBuild.DEBUG_BUILD && core.debug.log(`[Tracing] Skipping duplicate navigation for location: ${locationKey}`);
745
+ }
746
+ return;
747
+ }
595
748
 
596
- // Cross usage can result in multiple navigation spans being created without this check
597
- if (!isAlreadyInNavigationSpan) {
598
- const navigationSpan = browser.startBrowserTracingNavigationSpan(client, {
599
- name,
749
+ // Create new navigation span (first navigation or legitimate new navigation)
750
+ // Reserve the spot in the map first to prevent race conditions
751
+ // Mark as placeholder to prevent concurrent handleNavigation calls from creating duplicates
752
+ const placeholderSpan = { end: () => {} } ;
753
+ const placeholderEntry = {
754
+ span: placeholderSpan,
755
+ routeName: name,
756
+ pathname: location.pathname,
757
+ locationKey,
758
+ isPlaceholder: true ,
759
+ };
760
+ activeNavigationSpans.set(client, placeholderEntry);
761
+
762
+ let navigationSpan;
763
+ try {
764
+ navigationSpan = browser.startBrowserTracingNavigationSpan(client, {
765
+ name: placeholderEntry.routeName, // Use placeholder's routeName in case it was updated
600
766
  attributes: {
601
767
  [core.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
602
768
  [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
603
769
  [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`,
604
770
  },
605
771
  });
772
+ } catch (e) {
773
+ // If span creation fails, remove the placeholder so we don't block future navigations
774
+ activeNavigationSpans.delete(client);
775
+ throw e;
776
+ }
606
777
 
607
- // Patch navigation span to handle early cancellation (e.g., document.hidden)
608
- if (navigationSpan) {
609
- patchNavigationSpanEnd(navigationSpan, location, routes, basename, allRoutes);
610
- }
778
+ if (navigationSpan) {
779
+ // Update the map with the real span (isPlaceholder omitted, defaults to false)
780
+ activeNavigationSpans.set(client, {
781
+ span: navigationSpan,
782
+ routeName: placeholderEntry.routeName, // Use the (potentially updated) placeholder routeName
783
+ pathname: location.pathname,
784
+ locationKey,
785
+ });
786
+ patchSpanEnd(navigationSpan, location, routes, basename, allRoutes, 'navigation');
787
+ } else {
788
+ // If no span was created, remove the placeholder
789
+ activeNavigationSpans.delete(client);
611
790
  }
612
791
  }
613
792
  }
@@ -656,7 +835,13 @@ function updatePageloadTransaction({
656
835
  : (_matchRoutes(allRoutes || routes, location, basename) );
657
836
 
658
837
  if (branches) {
659
- const [name, source] = utils.resolveRouteNameAndSource(location, routes, allRoutes || routes, branches, basename);
838
+ const [name, source] = utils.resolveRouteNameAndSource(
839
+ location,
840
+ allRoutes || routes,
841
+ allRoutes || routes,
842
+ branches,
843
+ basename,
844
+ );
660
845
 
661
846
  core.getCurrentScope().setTransactionName(name || '/');
662
847
 
@@ -665,11 +850,93 @@ function updatePageloadTransaction({
665
850
  activeRootSpan.setAttribute(core.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
666
851
 
667
852
  // Patch span.end() to ensure we update the name one last time before the span is sent
668
- patchPageloadSpanEnd(activeRootSpan, location, routes, basename, allRoutes);
853
+ patchSpanEnd(activeRootSpan, location, routes, basename, allRoutes, 'pageload');
669
854
  }
670
855
  }
671
856
  }
672
857
 
858
+ /**
859
+ * Determines if a span name should be updated during wildcard route resolution.
860
+ *
861
+ * Update conditions (in priority order):
862
+ * 1. No current name + allowNoCurrentName: true → always update (pageload spans)
863
+ * 2. Current name has wildcard + new is route without wildcard → upgrade (e.g., "/users/*" → "/users/:id")
864
+ * 3. Current source is not 'route' + new source is 'route' → upgrade (e.g., URL → parameterized route)
865
+ *
866
+ * @param currentName - The current span name (may be undefined)
867
+ * @param currentSource - The current span source ('route', 'url', or undefined)
868
+ * @param newName - The proposed new span name
869
+ * @param newSource - The proposed new span source
870
+ * @param allowNoCurrentName - If true, allow updates when there's no current name (for pageload spans)
871
+ * @returns true if the span name should be updated
872
+ */
873
+ function shouldUpdateWildcardSpanName(
874
+ currentName,
875
+ currentSource,
876
+ newName,
877
+ newSource,
878
+ allowNoCurrentName = false,
879
+ ) {
880
+ if (!newName) {
881
+ return false;
882
+ }
883
+
884
+ if (!currentName && allowNoCurrentName) {
885
+ return true;
886
+ }
887
+
888
+ const hasWildcard = currentName && utils.transactionNameHasWildcard(currentName);
889
+
890
+ if (hasWildcard && newSource === 'route' && !utils.transactionNameHasWildcard(newName)) {
891
+ return true;
892
+ }
893
+
894
+ if (currentSource !== 'route' && newSource === 'route') {
895
+ return true;
896
+ }
897
+
898
+ return false;
899
+ }
900
+
901
+ function tryUpdateSpanNameBeforeEnd(
902
+ span,
903
+ spanJson,
904
+ currentName,
905
+ location,
906
+ routes,
907
+ basename,
908
+ spanType,
909
+ allRoutes,
910
+ ) {
911
+ try {
912
+ const currentSource = spanJson.data?.[core.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
913
+
914
+ if (currentSource === 'route' && currentName && !utils.transactionNameHasWildcard(currentName)) {
915
+ return;
916
+ }
917
+
918
+ const currentAllRoutes = Array.from(allRoutes);
919
+ const routesToUse = currentAllRoutes.length > 0 ? currentAllRoutes : routes;
920
+ const branches = _matchRoutes(routesToUse, location, basename) ;
921
+
922
+ if (!branches) {
923
+ return;
924
+ }
925
+
926
+ const [name, source] = utils.resolveRouteNameAndSource(location, routesToUse, routesToUse, branches, basename);
927
+
928
+ const isImprovement = shouldUpdateWildcardSpanName(currentName, currentSource, name, source, true);
929
+ const spanNotEnded = spanType === 'pageload' || !spanJson.timestamp;
930
+
931
+ if (isImprovement && spanNotEnded) {
932
+ span.updateName(name);
933
+ span.setAttribute(core.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
934
+ }
935
+ } catch (error) {
936
+ debugBuild.DEBUG_BUILD && core.debug.warn(`Error updating span details before ending: ${error}`);
937
+ }
938
+ }
939
+
673
940
  /**
674
941
  * Patches the span.end() method to update the transaction name one last time before the span is sent.
675
942
  * This handles cases where the span is cancelled early (e.g., document.hidden) before lazy routes have finished loading.
@@ -689,71 +956,93 @@ function patchSpanEnd(
689
956
  return;
690
957
  }
691
958
 
959
+ // Use the passed route context, or fall back to global Set
960
+ const allRoutesSet = _allRoutes ? new Set(_allRoutes) : allRoutes;
961
+
692
962
  const originalEnd = span.end.bind(span);
963
+ let endCalled = false;
693
964
 
694
965
  span.end = function patchedEnd(...args) {
695
- try {
696
- // Only update if the span source is not already 'route' (i.e., it hasn't been parameterized yet)
697
- const spanJson = core.spanToJSON(span);
698
- const currentSource = spanJson.data?.[core.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
699
- if (currentSource !== 'route') {
700
- // Last chance to update the transaction name with the latest route info
701
- // Use the live global allRoutes Set to include any lazy routes loaded after patching
702
- const currentAllRoutes = Array.from(allRoutes);
703
- const branches = _matchRoutes(
704
- currentAllRoutes.length > 0 ? currentAllRoutes : routes,
705
- location,
706
- basename,
707
- ) ;
966
+ if (endCalled) {
967
+ return;
968
+ }
969
+ endCalled = true;
970
+
971
+ // Capture timestamp immediately to avoid delay from async operations
972
+ // If no timestamp was provided, capture the current time now
973
+ const endTimestamp = args.length > 0 ? args[0] : Date.now() / 1000;
974
+
975
+ const spanJson = core.spanToJSON(span);
976
+ const currentName = spanJson.description;
977
+ const currentSource = spanJson.data?.[core.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
978
+
979
+ // Helper to clean up activeNavigationSpans after span ends
980
+ const cleanupNavigationSpan = () => {
981
+ const client = core.getClient();
982
+ if (client && spanType === 'navigation') {
983
+ const trackedNav = activeNavigationSpans.get(client);
984
+ if (trackedNav && trackedNav.span === span) {
985
+ activeNavigationSpans.delete(client);
986
+ }
987
+ }
988
+ };
989
+
990
+ const pendingPromises = pendingLazyRouteLoads.get(span);
991
+ // Wait for lazy routes if:
992
+ // 1. There are pending promises AND
993
+ // 2. Current name exists AND
994
+ // 3. Either the name has a wildcard OR the source is not 'route' (URL-based names)
995
+ const shouldWaitForLazyRoutes =
996
+ pendingPromises &&
997
+ pendingPromises.size > 0 &&
998
+ currentName &&
999
+ (utils.transactionNameHasWildcard(currentName) || currentSource !== 'route');
1000
+
1001
+ if (shouldWaitForLazyRoutes) {
1002
+ if (_lazyRouteTimeout === 0) {
1003
+ tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutesSet);
1004
+ cleanupNavigationSpan();
1005
+ originalEnd(endTimestamp);
1006
+ return;
1007
+ }
708
1008
 
709
- if (branches) {
710
- const [name, source] = utils.resolveRouteNameAndSource(
1009
+ const allSettled = Promise.allSettled(pendingPromises).then(() => {});
1010
+ const waitPromise =
1011
+ _lazyRouteTimeout === Infinity
1012
+ ? allSettled
1013
+ : Promise.race([allSettled, new Promise(r => setTimeout(r, _lazyRouteTimeout))]);
1014
+
1015
+ waitPromise
1016
+ .then(() => {
1017
+ const updatedSpanJson = core.spanToJSON(span);
1018
+ tryUpdateSpanNameBeforeEnd(
1019
+ span,
1020
+ updatedSpanJson,
1021
+ updatedSpanJson.description,
711
1022
  location,
712
1023
  routes,
713
- currentAllRoutes.length > 0 ? currentAllRoutes : routes,
714
- branches,
715
1024
  basename,
1025
+ spanType,
1026
+ allRoutesSet,
716
1027
  );
717
-
718
- // Only update if we have a valid name
719
- if (name && (spanType === 'pageload' || !spanJson.timestamp)) {
720
- span.updateName(name);
721
- span.setAttribute(core.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
722
- }
723
- }
724
- }
725
- } catch (error) {
726
- // Silently catch errors to ensure span.end() is always called
727
- debugBuild.DEBUG_BUILD && core.debug.warn(`Error updating span details before ending: ${error}`);
1028
+ cleanupNavigationSpan();
1029
+ originalEnd(endTimestamp);
1030
+ })
1031
+ .catch(() => {
1032
+ cleanupNavigationSpan();
1033
+ originalEnd(endTimestamp);
1034
+ });
1035
+ return;
728
1036
  }
729
1037
 
730
- return originalEnd(...args);
1038
+ tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutesSet);
1039
+ cleanupNavigationSpan();
1040
+ originalEnd(endTimestamp);
731
1041
  };
732
1042
 
733
- // Mark this span as having its end() method patched to prevent duplicate patching
734
1043
  core.addNonEnumerableProperty(span , patchedPropertyName, true);
735
1044
  }
736
1045
 
737
- function patchPageloadSpanEnd(
738
- span,
739
- location,
740
- routes,
741
- basename,
742
- _allRoutes,
743
- ) {
744
- patchSpanEnd(span, location, routes, basename, _allRoutes, 'pageload');
745
- }
746
-
747
- function patchNavigationSpanEnd(
748
- span,
749
- location,
750
- routes,
751
- basename,
752
- _allRoutes,
753
- ) {
754
- patchSpanEnd(span, location, routes, basename, _allRoutes, 'navigation');
755
- }
756
-
757
1046
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
758
1047
  function createV6CompatibleWithSentryReactRouterRouting(
759
1048
  Routes,
@@ -789,11 +1078,13 @@ function createV6CompatibleWithSentryReactRouterRouting(
789
1078
  });
790
1079
  isMountRenderPass.current = false;
791
1080
  } else {
1081
+ // Note: Component-based routes don't support lazy route tracking via lazyRouteTimeout
1082
+ // because React.lazy() loads happen at the component level, not the router level.
1083
+ // Use createBrowserRouter with patchRoutesOnNavigation for lazy route tracking.
792
1084
  handleNavigation({ location, routes, navigationType, version, allRoutes: Array.from(allRoutes) });
793
1085
  }
794
1086
  },
795
- // `props.children` is purposely not included in the dependency array, because we do not want to re-run this effect
796
- // when the children change. We only want to start transactions when the location or navigation type change.
1087
+ // Re-run only on location/navigation changes, not children changes
797
1088
  [location, navigationType],
798
1089
  );
799
1090
 
@@ -826,6 +1117,7 @@ function getActiveRootSpan() {
826
1117
  exports.addResolvedRoutesToParent = addResolvedRoutesToParent;
827
1118
  exports.addRoutesToAllRoutes = addRoutesToAllRoutes;
828
1119
  exports.allRoutes = allRoutes;
1120
+ exports.computeLocationKey = computeLocationKey;
829
1121
  exports.createReactRouterV6CompatibleTracingIntegration = createReactRouterV6CompatibleTracingIntegration;
830
1122
  exports.createV6CompatibleWithSentryReactRouterRouting = createV6CompatibleWithSentryReactRouterRouting;
831
1123
  exports.createV6CompatibleWrapCreateBrowserRouter = createV6CompatibleWrapCreateBrowserRouter;
@@ -833,5 +1125,6 @@ exports.createV6CompatibleWrapCreateMemoryRouter = createV6CompatibleWrapCreateM
833
1125
  exports.createV6CompatibleWrapUseRoutes = createV6CompatibleWrapUseRoutes;
834
1126
  exports.handleNavigation = handleNavigation;
835
1127
  exports.processResolvedRoutes = processResolvedRoutes;
1128
+ exports.shouldSkipNavigation = shouldSkipNavigation;
836
1129
  exports.updateNavigationSpan = updateNavigationSpan;
837
1130
  //# sourceMappingURL=instrumentation.js.map