@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.
@@ -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,14 +16,112 @@ 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
- * Adds resolved routes as children to the parent route.
25
- * Prevents duplicate routes by checking if they already exist.
37
+ * Schedules a callback using requestAnimationFrame when available (browser),
38
+ * or falls back to setTimeout for SSR environments (Node.js, createMemoryRouter tests).
26
39
  */
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
+ }
124
+
27
125
  function addResolvedRoutesToParent(resolvedRoutes, parentRoute) {
28
126
  const existingChildren = parentRoute.children || [];
29
127
 
@@ -42,9 +140,23 @@ function addResolvedRoutesToParent(resolvedRoutes, parentRoute) {
42
140
  }
43
141
  }
44
142
 
45
- // Keeping as a global variable for cross-usage in multiple functions
46
- // only exported for testing purposes
47
- const allRoutes = new Set();
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);
151
+
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
+ }
48
160
 
49
161
  /**
50
162
  * Processes resolved routes by adding them to allRoutes and checking for nested async handlers.
@@ -110,13 +222,14 @@ function updateNavigationSpan(
110
222
  forceUpdate = false,
111
223
  matchRoutes,
112
224
  ) {
113
- // Check if this span has already been named to avoid multiple updates
114
- // But allow updates if this is a forced update (e.g., when lazy routes are loaded)
115
- const hasBeenNamed =
116
- !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;
117
231
 
118
- if (!hasBeenNamed) {
119
- // Get fresh branches for the current location with all loaded routes
232
+ if (shouldUpdate && !spanJson.timestamp) {
120
233
  const currentBranches = matchRoutes(allRoutes, location);
121
234
  const [name, source] = resolveRouteNameAndSource(
122
235
  location,
@@ -126,22 +239,105 @@ function updateNavigationSpan(
126
239
  '',
127
240
  );
128
241
 
129
- // Only update if we have a valid name and the span hasn't finished
130
- const spanJson = spanToJSON(activeRootSpan);
131
- 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) {
132
250
  activeRootSpan.updateName(name);
133
251
  activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
134
252
 
135
- // Mark this span as having its name set to prevent future updates
136
- addNonEnumerableProperty(
137
- activeRootSpan ,
138
- '__sentry_navigation_name_set__',
139
- true,
140
- );
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
+ }
141
261
  }
142
262
  }
143
263
  }
144
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
+
145
341
  /**
146
342
  * Creates a wrapCreateBrowserRouter function that can be used with all React Router v6 compatible versions.
147
343
  */
@@ -163,30 +359,17 @@ function createV6CompatibleWrapCreateBrowserRouter
163
359
  return function (routes, opts) {
164
360
  addRoutesToAllRoutes(routes);
165
361
 
166
- // Check for async handlers that might contain sub-route declarations (only if enabled)
167
362
  if (_enableAsyncRouteHandlers) {
168
363
  for (const route of routes) {
169
364
  checkRouteForAsyncHandler(route, processResolvedRoutes);
170
365
  }
171
366
  }
172
367
 
173
- // Wrap patchRoutesOnNavigation to detect when lazy routes are loaded
174
368
  const wrappedOpts = wrapPatchRoutesOnNavigation(opts);
175
-
176
369
  const router = createRouterFunction(routes, wrappedOpts);
177
370
  const basename = opts?.basename;
178
-
179
371
  const activeRootSpan = getActiveRootSpan();
180
372
 
181
- // Track whether we've completed the initial pageload to properly distinguish
182
- // between POPs that occur during pageload vs. legitimate back/forward navigation.
183
- let isInitialPageloadComplete = false;
184
- let hasSeenPageloadSpan = !!activeRootSpan && spanToJSON(activeRootSpan).op === 'pageload';
185
- let hasSeenPopAfterPageload = false;
186
-
187
- // The initial load ends when `createBrowserRouter` is called.
188
- // This is the earliest convenient time to update the transaction name.
189
- // Callbacks to `router.subscribe` are not called for the initial load.
190
373
  if (router.state.historyAction === 'POP' && activeRootSpan) {
191
374
  updatePageloadTransaction({
192
375
  activeRootSpan,
@@ -197,50 +380,7 @@ function createV6CompatibleWrapCreateBrowserRouter
197
380
  });
198
381
  }
199
382
 
200
- router.subscribe((state) => {
201
- // Track pageload completion to distinguish POPs during pageload from legitimate back/forward navigation
202
- if (!isInitialPageloadComplete) {
203
- const currentRootSpan = getActiveRootSpan();
204
- const isCurrentlyInPageload = currentRootSpan && spanToJSON(currentRootSpan).op === 'pageload';
205
-
206
- if (isCurrentlyInPageload) {
207
- hasSeenPageloadSpan = true;
208
- } else if (hasSeenPageloadSpan) {
209
- // Pageload span was active but is now gone - pageload has completed
210
- if (state.historyAction === 'POP' && !hasSeenPopAfterPageload) {
211
- // Pageload ended: ignore the first POP after pageload
212
- hasSeenPopAfterPageload = true;
213
- } else {
214
- // Pageload ended: either non-POP action or subsequent POP
215
- isInitialPageloadComplete = true;
216
- }
217
- }
218
- // If we haven't seen a pageload span yet, keep waiting (don't mark as complete)
219
- }
220
-
221
- const shouldHandleNavigation =
222
- state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete);
223
-
224
- if (shouldHandleNavigation) {
225
- const navigationHandler = () => {
226
- handleNavigation({
227
- location: state.location,
228
- routes,
229
- navigationType: state.historyAction,
230
- version,
231
- basename,
232
- allRoutes: Array.from(allRoutes),
233
- });
234
- };
235
-
236
- // Wait for the next render if loading an unsettled route
237
- if (state.navigation.state !== 'idle') {
238
- requestAnimationFrame(navigationHandler);
239
- } else {
240
- navigationHandler();
241
- }
242
- }
243
- });
383
+ setupRouterSubscription(router, routes, version, basename, activeRootSpan);
244
384
 
245
385
  return router;
246
386
  };
@@ -272,14 +412,12 @@ function createV6CompatibleWrapCreateMemoryRouter
272
412
  ) {
273
413
  addRoutesToAllRoutes(routes);
274
414
 
275
- // Check for async handlers that might contain sub-route declarations (only if enabled)
276
415
  if (_enableAsyncRouteHandlers) {
277
416
  for (const route of routes) {
278
417
  checkRouteForAsyncHandler(route, processResolvedRoutes);
279
418
  }
280
419
  }
281
420
 
282
- // Wrap patchRoutesOnNavigation to detect when lazy routes are loaded
283
421
  const wrappedOpts = wrapPatchRoutesOnNavigation(opts, true);
284
422
 
285
423
  const router = createRouterFunction(routes, wrappedOpts);
@@ -317,58 +455,7 @@ function createV6CompatibleWrapCreateMemoryRouter
317
455
  });
318
456
  }
319
457
 
320
- // Track whether we've completed the initial pageload to properly distinguish
321
- // between POPs that occur during pageload vs. legitimate back/forward navigation.
322
- let isInitialPageloadComplete = false;
323
- let hasSeenPageloadSpan = !!memoryActiveRootSpan && spanToJSON(memoryActiveRootSpan).op === 'pageload';
324
- let hasSeenPopAfterPageload = false;
325
-
326
- router.subscribe((state) => {
327
- // Track pageload completion to distinguish POPs during pageload from legitimate back/forward navigation
328
- if (!isInitialPageloadComplete) {
329
- const currentRootSpan = getActiveRootSpan();
330
- const isCurrentlyInPageload = currentRootSpan && spanToJSON(currentRootSpan).op === 'pageload';
331
-
332
- if (isCurrentlyInPageload) {
333
- hasSeenPageloadSpan = true;
334
- } else if (hasSeenPageloadSpan) {
335
- // Pageload span was active but is now gone - pageload has completed
336
- if (state.historyAction === 'POP' && !hasSeenPopAfterPageload) {
337
- // Pageload ended: ignore the first POP after pageload
338
- hasSeenPopAfterPageload = true;
339
- } else {
340
- // Pageload ended: either non-POP action or subsequent POP
341
- isInitialPageloadComplete = true;
342
- }
343
- }
344
- // If we haven't seen a pageload span yet, keep waiting (don't mark as complete)
345
- }
346
-
347
- const location = state.location;
348
-
349
- const shouldHandleNavigation =
350
- state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete);
351
-
352
- if (shouldHandleNavigation) {
353
- const navigationHandler = () => {
354
- handleNavigation({
355
- location,
356
- routes,
357
- navigationType: state.historyAction,
358
- version,
359
- basename,
360
- allRoutes: Array.from(allRoutes),
361
- });
362
- };
363
-
364
- // Wait for the next render if loading an unsettled route
365
- if (state.navigation.state !== 'idle') {
366
- requestAnimationFrame(navigationHandler);
367
- } else {
368
- navigationHandler();
369
- }
370
- }
371
- });
458
+ setupRouterSubscription(router, routes, version, basename, memoryActiveRootSpan);
372
459
 
373
460
  return router;
374
461
  };
@@ -393,6 +480,7 @@ function createReactRouterV6CompatibleTracingIntegration(
393
480
  enableAsyncRouteHandlers = false,
394
481
  instrumentPageLoad = true,
395
482
  instrumentNavigation = true,
483
+ lazyRouteTimeout,
396
484
  } = options;
397
485
 
398
486
  return {
@@ -400,6 +488,36 @@ function createReactRouterV6CompatibleTracingIntegration(
400
488
  setup(client) {
401
489
  integration.setup(client);
402
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
+
403
521
  _useEffect = useEffect;
404
522
  _useLocation = useLocation;
405
523
  _useNavigationType = useNavigationType;
@@ -472,6 +590,9 @@ function createV6CompatibleWrapUseRoutes(origUseRoutes, version) {
472
590
  });
473
591
  isMountRenderPass.current = false;
474
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.
475
596
  handleNavigation({
476
597
  location: normalizedLocation,
477
598
  routes,
@@ -506,7 +627,8 @@ function wrapPatchRoutesOnNavigation(
506
627
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
507
628
  const targetPath = (args )?.path;
508
629
 
509
- // For browser router, wrap the patch function to update span during patching
630
+ const activeRootSpan = getActiveRootSpan();
631
+
510
632
  if (!isMemoryRouter) {
511
633
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
512
634
  const originalPatch = (args )?.patch;
@@ -514,13 +636,13 @@ function wrapPatchRoutesOnNavigation(
514
636
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
515
637
  (args ).patch = (routeId, children) => {
516
638
  addRoutesToAllRoutes(children);
517
- const activeRootSpan = getActiveRootSpan();
518
- if (activeRootSpan && (spanToJSON(activeRootSpan) ).op === 'navigation') {
639
+ const currentActiveRootSpan = getActiveRootSpan();
640
+ if (currentActiveRootSpan && (spanToJSON(currentActiveRootSpan) ).op === 'navigation') {
519
641
  updateNavigationSpan(
520
- activeRootSpan,
642
+ currentActiveRootSpan,
521
643
  { pathname: targetPath, search: '', hash: '', state: null, key: 'default' },
522
644
  Array.from(allRoutes),
523
- true, // forceUpdate = true since we're loading lazy routes
645
+ true,
524
646
  _matchRoutes,
525
647
  );
526
648
  }
@@ -529,50 +651,48 @@ function wrapPatchRoutesOnNavigation(
529
651
  }
530
652
  }
531
653
 
532
- const result = await originalPatchRoutes(args);
533
-
534
- // Update navigation span after routes are patched
535
- const activeRootSpan = getActiveRootSpan();
536
- if (activeRootSpan && (spanToJSON(activeRootSpan) ).op === 'navigation') {
537
- // Determine pathname based on router type
538
- let pathname;
539
- if (isMemoryRouter) {
540
- // For memory routers, only use targetPath
541
- pathname = targetPath;
542
- } else {
543
- // For browser routers, use targetPath or fall back to window.location
544
- 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
+ }
545
670
  }
546
671
 
547
- if (pathname) {
548
- updateNavigationSpan(
549
- activeRootSpan,
550
- { pathname, search: '', hash: '', state: null, key: 'default' },
551
- Array.from(allRoutes),
552
- false, // forceUpdate = false since this is after lazy routes are loaded
553
- _matchRoutes,
554
- );
555
- }
672
+ return result;
673
+ })();
674
+
675
+ if (activeRootSpan) {
676
+ trackLazyRouteLoad(activeRootSpan, lazyLoadPromise);
556
677
  }
557
678
 
558
- return result;
679
+ return lazyLoadPromise;
559
680
  },
560
681
  };
561
682
  }
562
683
 
684
+ // eslint-disable-next-line complexity
563
685
  function handleNavigation(opts
564
686
 
565
687
  ) {
566
688
  const { location, routes, navigationType, version, matches, basename, allRoutes } = opts;
567
- const branches = Array.isArray(matches) ? matches : _matchRoutes(routes, location, basename);
689
+ const branches = Array.isArray(matches) ? matches : _matchRoutes(allRoutes || routes, location, basename);
568
690
 
569
691
  const client = getClient();
570
692
  if (!client || !CLIENTS_WITH_INSTRUMENT_NAVIGATION.has(client)) {
571
693
  return;
572
694
  }
573
695
 
574
- // Avoid starting a navigation span on initial load when a pageload root span is active.
575
- // This commonly happens when lazy routes resolve during the first render and React Router emits a POP.
576
696
  const activeRootSpan = getActiveRootSpan();
577
697
  if (activeRootSpan && spanToJSON(activeRootSpan).op === 'pageload' && navigationType === 'POP') {
578
698
  return;
@@ -581,31 +701,90 @@ function handleNavigation(opts
581
701
  if ((navigationType === 'PUSH' || navigationType === 'POP') && branches) {
582
702
  const [name, source] = resolveRouteNameAndSource(
583
703
  location,
584
- routes,
704
+ allRoutes || routes,
585
705
  allRoutes || routes,
586
706
  branches ,
587
707
  basename,
588
708
  );
589
709
 
590
- const activeSpan = getActiveSpan();
591
- const spanJson = activeSpan && spanToJSON(activeSpan);
592
- const isAlreadyInNavigationSpan = spanJson?.op === 'navigation';
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
+ }
593
746
 
594
- // Cross usage can result in multiple navigation spans being created without this check
595
- if (!isAlreadyInNavigationSpan) {
596
- const navigationSpan = startBrowserTracingNavigationSpan(client, {
597
- name,
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
598
764
  attributes: {
599
765
  [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
600
766
  [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
601
767
  [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`,
602
768
  },
603
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
+ }
604
775
 
605
- // Patch navigation span to handle early cancellation (e.g., document.hidden)
606
- if (navigationSpan) {
607
- patchNavigationSpanEnd(navigationSpan, location, routes, basename, allRoutes);
608
- }
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,
783
+ });
784
+ patchSpanEnd(navigationSpan, location, routes, basename, allRoutes, 'navigation');
785
+ } else {
786
+ // If no span was created, remove the placeholder
787
+ activeNavigationSpans.delete(client);
609
788
  }
610
789
  }
611
790
  }
@@ -654,7 +833,13 @@ function updatePageloadTransaction({
654
833
  : (_matchRoutes(allRoutes || routes, location, basename) );
655
834
 
656
835
  if (branches) {
657
- const [name, source] = resolveRouteNameAndSource(location, routes, allRoutes || routes, branches, basename);
836
+ const [name, source] = resolveRouteNameAndSource(
837
+ location,
838
+ allRoutes || routes,
839
+ allRoutes || routes,
840
+ branches,
841
+ basename,
842
+ );
658
843
 
659
844
  getCurrentScope().setTransactionName(name || '/');
660
845
 
@@ -663,11 +848,93 @@ function updatePageloadTransaction({
663
848
  activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
664
849
 
665
850
  // Patch span.end() to ensure we update the name one last time before the span is sent
666
- patchPageloadSpanEnd(activeRootSpan, location, routes, basename, allRoutes);
851
+ patchSpanEnd(activeRootSpan, location, routes, basename, allRoutes, 'pageload');
667
852
  }
668
853
  }
669
854
  }
670
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;
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}`);
935
+ }
936
+ }
937
+
671
938
  /**
672
939
  * Patches the span.end() method to update the transaction name one last time before the span is sent.
673
940
  * This handles cases where the span is cancelled early (e.g., document.hidden) before lazy routes have finished loading.
@@ -687,71 +954,93 @@ function patchSpanEnd(
687
954
  return;
688
955
  }
689
956
 
957
+ // Use the passed route context, or fall back to global Set
958
+ const allRoutesSet = _allRoutes ? new Set(_allRoutes) : allRoutes;
959
+
690
960
  const originalEnd = span.end.bind(span);
961
+ let endCalled = false;
691
962
 
692
963
  span.end = function patchedEnd(...args) {
693
- try {
694
- // Only update if the span source is not already 'route' (i.e., it hasn't been parameterized yet)
695
- const spanJson = spanToJSON(span);
696
- const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
697
- if (currentSource !== 'route') {
698
- // Last chance to update the transaction name with the latest route info
699
- // Use the live global allRoutes Set to include any lazy routes loaded after patching
700
- const currentAllRoutes = Array.from(allRoutes);
701
- const branches = _matchRoutes(
702
- currentAllRoutes.length > 0 ? currentAllRoutes : routes,
703
- location,
704
- basename,
705
- ) ;
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
+ }
706
1006
 
707
- if (branches) {
708
- 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,
709
1020
  location,
710
1021
  routes,
711
- currentAllRoutes.length > 0 ? currentAllRoutes : routes,
712
- branches,
713
1022
  basename,
1023
+ spanType,
1024
+ allRoutesSet,
714
1025
  );
715
-
716
- // Only update if we have a valid name
717
- if (name && (spanType === 'pageload' || !spanJson.timestamp)) {
718
- span.updateName(name);
719
- span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
720
- }
721
- }
722
- }
723
- } catch (error) {
724
- // Silently catch errors to ensure span.end() is always called
725
- 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;
726
1034
  }
727
1035
 
728
- return originalEnd(...args);
1036
+ tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutesSet);
1037
+ cleanupNavigationSpan();
1038
+ originalEnd(endTimestamp);
729
1039
  };
730
1040
 
731
- // Mark this span as having its end() method patched to prevent duplicate patching
732
1041
  addNonEnumerableProperty(span , patchedPropertyName, true);
733
1042
  }
734
1043
 
735
- function patchPageloadSpanEnd(
736
- span,
737
- location,
738
- routes,
739
- basename,
740
- _allRoutes,
741
- ) {
742
- patchSpanEnd(span, location, routes, basename, _allRoutes, 'pageload');
743
- }
744
-
745
- function patchNavigationSpanEnd(
746
- span,
747
- location,
748
- routes,
749
- basename,
750
- _allRoutes,
751
- ) {
752
- patchSpanEnd(span, location, routes, basename, _allRoutes, 'navigation');
753
- }
754
-
755
1044
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
756
1045
  function createV6CompatibleWithSentryReactRouterRouting(
757
1046
  Routes,
@@ -787,11 +1076,13 @@ function createV6CompatibleWithSentryReactRouterRouting(
787
1076
  });
788
1077
  isMountRenderPass.current = false;
789
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.
790
1082
  handleNavigation({ location, routes, navigationType, version, allRoutes: Array.from(allRoutes) });
791
1083
  }
792
1084
  },
793
- // `props.children` is purposely not included in the dependency array, because we do not want to re-run this effect
794
- // 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
795
1086
  [location, navigationType],
796
1087
  );
797
1088
 
@@ -821,5 +1112,5 @@ function getActiveRootSpan() {
821
1112
  return op === 'navigation' || op === 'pageload' ? rootSpan : undefined;
822
1113
  }
823
1114
 
824
- 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 };
825
1116
  //# sourceMappingURL=instrumentation.js.map