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