@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.
@@ -18,15 +18,111 @@ 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
- * Tracks last navigation per client to prevent duplicate spans in cross-usage scenarios.
27
- * Entry persists until the navigation span ends, allowing cross-usage detection during delayed wrapper execution.
39
+ * Schedules a callback using requestAnimationFrame when available (browser),
40
+ * or falls back to setTimeout for SSR environments (Node.js, createMemoryRouter tests).
28
41
  */
29
- const LAST_NAVIGATION_PER_CLIENT = new WeakMap();
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
+ }
30
126
 
31
127
  function addResolvedRoutesToParent(resolvedRoutes, parentRoute) {
32
128
  const existingChildren = parentRoute.children || [];
@@ -46,29 +142,23 @@ function addResolvedRoutesToParent(resolvedRoutes, parentRoute) {
46
142
  }
47
143
  }
48
144
 
49
- /**
50
- * Determines if a navigation should be handled based on router state.
51
- * Only handles:
52
- * - PUSH navigations (always)
53
- * - POP navigations (only after initial pageload is complete)
54
- * - When router state is 'idle' (not 'loading' or 'submitting')
55
- *
56
- * During 'loading' or 'submitting', state.location may still have the old pathname,
57
- * which would cause us to create a span for the wrong route.
58
- */
59
- function shouldHandleNavigation(
60
- state,
61
- isInitialPageloadComplete,
62
- ) {
63
- return (
64
- (state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete)) &&
65
- state.navigation.state === 'idle'
66
- );
67
- }
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);
68
153
 
69
- // Keeping as a global variable for cross-usage in multiple functions
70
- // only exported for testing purposes
71
- const allRoutes = new Set();
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
+ }
72
162
 
73
163
  /**
74
164
  * Processes resolved routes by adding them to allRoutes and checking for nested async handlers.
@@ -134,13 +224,14 @@ function updateNavigationSpan(
134
224
  forceUpdate = false,
135
225
  matchRoutes,
136
226
  ) {
137
- // Check if this span has already been named to avoid multiple updates
138
- // But allow updates if this is a forced update (e.g., when lazy routes are loaded)
139
- const hasBeenNamed =
140
- !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;
141
233
 
142
- if (!hasBeenNamed) {
143
- // Get fresh branches for the current location with all loaded routes
234
+ if (shouldUpdate && !spanJson.timestamp) {
144
235
  const currentBranches = matchRoutes(allRoutes, location);
145
236
  const [name, source] = utils.resolveRouteNameAndSource(
146
237
  location,
@@ -150,22 +241,105 @@ function updateNavigationSpan(
150
241
  '',
151
242
  );
152
243
 
153
- // Only update if we have a valid name and the span hasn't finished
154
- const spanJson = core.spanToJSON(activeRootSpan);
155
- 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) {
156
252
  activeRootSpan.updateName(name);
157
253
  activeRootSpan.setAttribute(core.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
158
254
 
159
- // Mark this span as having its name set to prevent future updates
160
- core.addNonEnumerableProperty(
161
- activeRootSpan ,
162
- '__sentry_navigation_name_set__',
163
- true,
164
- );
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
+ }
165
263
  }
166
264
  }
167
265
  }
168
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
+
169
343
  /**
170
344
  * Creates a wrapCreateBrowserRouter function that can be used with all React Router v6 compatible versions.
171
345
  */
@@ -187,30 +361,17 @@ function createV6CompatibleWrapCreateBrowserRouter
187
361
  return function (routes, opts) {
188
362
  addRoutesToAllRoutes(routes);
189
363
 
190
- // Check for async handlers that might contain sub-route declarations (only if enabled)
191
364
  if (_enableAsyncRouteHandlers) {
192
365
  for (const route of routes) {
193
366
  lazyRoutes.checkRouteForAsyncHandler(route, processResolvedRoutes);
194
367
  }
195
368
  }
196
369
 
197
- // Wrap patchRoutesOnNavigation to detect when lazy routes are loaded
198
370
  const wrappedOpts = wrapPatchRoutesOnNavigation(opts);
199
-
200
371
  const router = createRouterFunction(routes, wrappedOpts);
201
372
  const basename = opts?.basename;
202
-
203
373
  const activeRootSpan = getActiveRootSpan();
204
374
 
205
- // Track whether we've completed the initial pageload to properly distinguish
206
- // between POPs that occur during pageload vs. legitimate back/forward navigation.
207
- let isInitialPageloadComplete = false;
208
- let hasSeenPageloadSpan = !!activeRootSpan && core.spanToJSON(activeRootSpan).op === 'pageload';
209
- let hasSeenPopAfterPageload = false;
210
-
211
- // The initial load ends when `createBrowserRouter` is called.
212
- // This is the earliest convenient time to update the transaction name.
213
- // Callbacks to `router.subscribe` are not called for the initial load.
214
375
  if (router.state.historyAction === 'POP' && activeRootSpan) {
215
376
  updatePageloadTransaction({
216
377
  activeRootSpan,
@@ -221,38 +382,7 @@ function createV6CompatibleWrapCreateBrowserRouter
221
382
  });
222
383
  }
223
384
 
224
- router.subscribe((state) => {
225
- // Track pageload completion to distinguish POPs during pageload from legitimate back/forward navigation
226
- if (!isInitialPageloadComplete) {
227
- const currentRootSpan = getActiveRootSpan();
228
- const isCurrentlyInPageload = currentRootSpan && core.spanToJSON(currentRootSpan).op === 'pageload';
229
-
230
- if (isCurrentlyInPageload) {
231
- hasSeenPageloadSpan = true;
232
- } else if (hasSeenPageloadSpan) {
233
- // Pageload span was active but is now gone - pageload has completed
234
- if (state.historyAction === 'POP' && !hasSeenPopAfterPageload) {
235
- // Pageload ended: ignore the first POP after pageload
236
- hasSeenPopAfterPageload = true;
237
- } else {
238
- // Pageload ended: either non-POP action or subsequent POP
239
- isInitialPageloadComplete = true;
240
- }
241
- }
242
- // If we haven't seen a pageload span yet, keep waiting (don't mark as complete)
243
- }
244
-
245
- if (shouldHandleNavigation(state, isInitialPageloadComplete)) {
246
- handleNavigation({
247
- location: state.location,
248
- routes,
249
- navigationType: state.historyAction,
250
- version,
251
- basename,
252
- allRoutes: Array.from(allRoutes),
253
- });
254
- }
255
- });
385
+ setupRouterSubscription(router, routes, version, basename, activeRootSpan);
256
386
 
257
387
  return router;
258
388
  };
@@ -284,14 +414,12 @@ function createV6CompatibleWrapCreateMemoryRouter
284
414
  ) {
285
415
  addRoutesToAllRoutes(routes);
286
416
 
287
- // Check for async handlers that might contain sub-route declarations (only if enabled)
288
417
  if (_enableAsyncRouteHandlers) {
289
418
  for (const route of routes) {
290
419
  lazyRoutes.checkRouteForAsyncHandler(route, processResolvedRoutes);
291
420
  }
292
421
  }
293
422
 
294
- // Wrap patchRoutesOnNavigation to detect when lazy routes are loaded
295
423
  const wrappedOpts = wrapPatchRoutesOnNavigation(opts, true);
296
424
 
297
425
  const router = createRouterFunction(routes, wrappedOpts);
@@ -329,44 +457,7 @@ function createV6CompatibleWrapCreateMemoryRouter
329
457
  });
330
458
  }
331
459
 
332
- // Track whether we've completed the initial pageload to properly distinguish
333
- // between POPs that occur during pageload vs. legitimate back/forward navigation.
334
- let isInitialPageloadComplete = false;
335
- let hasSeenPageloadSpan = !!memoryActiveRootSpan && core.spanToJSON(memoryActiveRootSpan).op === 'pageload';
336
- let hasSeenPopAfterPageload = false;
337
-
338
- router.subscribe((state) => {
339
- // Track pageload completion to distinguish POPs during pageload from legitimate back/forward navigation
340
- if (!isInitialPageloadComplete) {
341
- const currentRootSpan = getActiveRootSpan();
342
- const isCurrentlyInPageload = currentRootSpan && core.spanToJSON(currentRootSpan).op === 'pageload';
343
-
344
- if (isCurrentlyInPageload) {
345
- hasSeenPageloadSpan = true;
346
- } else if (hasSeenPageloadSpan) {
347
- // Pageload span was active but is now gone - pageload has completed
348
- if (state.historyAction === 'POP' && !hasSeenPopAfterPageload) {
349
- // Pageload ended: ignore the first POP after pageload
350
- hasSeenPopAfterPageload = true;
351
- } else {
352
- // Pageload ended: either non-POP action or subsequent POP
353
- isInitialPageloadComplete = true;
354
- }
355
- }
356
- // If we haven't seen a pageload span yet, keep waiting (don't mark as complete)
357
- }
358
-
359
- if (shouldHandleNavigation(state, isInitialPageloadComplete)) {
360
- handleNavigation({
361
- location: state.location,
362
- routes,
363
- navigationType: state.historyAction,
364
- version,
365
- basename,
366
- allRoutes: Array.from(allRoutes),
367
- });
368
- }
369
- });
460
+ setupRouterSubscription(router, routes, version, basename, memoryActiveRootSpan);
370
461
 
371
462
  return router;
372
463
  };
@@ -391,6 +482,7 @@ function createReactRouterV6CompatibleTracingIntegration(
391
482
  enableAsyncRouteHandlers = false,
392
483
  instrumentPageLoad = true,
393
484
  instrumentNavigation = true,
485
+ lazyRouteTimeout,
394
486
  } = options;
395
487
 
396
488
  return {
@@ -398,6 +490,36 @@ function createReactRouterV6CompatibleTracingIntegration(
398
490
  setup(client) {
399
491
  integration.setup(client);
400
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
+
401
523
  _useEffect = useEffect;
402
524
  _useLocation = useLocation;
403
525
  _useNavigationType = useNavigationType;
@@ -470,6 +592,9 @@ function createV6CompatibleWrapUseRoutes(origUseRoutes, version) {
470
592
  });
471
593
  isMountRenderPass.current = false;
472
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.
473
598
  handleNavigation({
474
599
  location: normalizedLocation,
475
600
  routes,
@@ -504,7 +629,8 @@ function wrapPatchRoutesOnNavigation(
504
629
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
505
630
  const targetPath = (args )?.path;
506
631
 
507
- // For browser router, wrap the patch function to update span during patching
632
+ const activeRootSpan = getActiveRootSpan();
633
+
508
634
  if (!isMemoryRouter) {
509
635
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
510
636
  const originalPatch = (args )?.patch;
@@ -512,13 +638,13 @@ function wrapPatchRoutesOnNavigation(
512
638
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
513
639
  (args ).patch = (routeId, children) => {
514
640
  addRoutesToAllRoutes(children);
515
- const activeRootSpan = getActiveRootSpan();
516
- if (activeRootSpan && (core.spanToJSON(activeRootSpan) ).op === 'navigation') {
641
+ const currentActiveRootSpan = getActiveRootSpan();
642
+ if (currentActiveRootSpan && (core.spanToJSON(currentActiveRootSpan) ).op === 'navigation') {
517
643
  updateNavigationSpan(
518
- activeRootSpan,
644
+ currentActiveRootSpan,
519
645
  { pathname: targetPath, search: '', hash: '', state: null, key: 'default' },
520
646
  Array.from(allRoutes),
521
- true, // forceUpdate = true since we're loading lazy routes
647
+ true,
522
648
  _matchRoutes,
523
649
  );
524
650
  }
@@ -527,94 +653,37 @@ function wrapPatchRoutesOnNavigation(
527
653
  }
528
654
  }
529
655
 
530
- const result = await originalPatchRoutes(args);
531
-
532
- // Update navigation span after routes are patched
533
- const activeRootSpan = getActiveRootSpan();
534
- if (activeRootSpan && (core.spanToJSON(activeRootSpan) ).op === 'navigation') {
535
- // Determine pathname based on router type
536
- let pathname;
537
- if (isMemoryRouter) {
538
- // For memory routers, only use targetPath
539
- pathname = targetPath;
540
- } else {
541
- // For browser routers, use targetPath or fall back to window.location
542
- 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
+ }
543
672
  }
544
673
 
545
- if (pathname) {
546
- updateNavigationSpan(
547
- activeRootSpan,
548
- { pathname, search: '', hash: '', state: null, key: 'default' },
549
- Array.from(allRoutes),
550
- false, // forceUpdate = false since this is after lazy routes are loaded
551
- _matchRoutes,
552
- );
553
- }
674
+ return result;
675
+ })();
676
+
677
+ if (activeRootSpan) {
678
+ trackLazyRouteLoad(activeRootSpan, lazyLoadPromise);
554
679
  }
555
680
 
556
- return result;
681
+ return lazyLoadPromise;
557
682
  },
558
683
  };
559
684
  }
560
685
 
561
- function getNavigationKey(location) {
562
- return `${location.pathname}${location.search}${location.hash}`;
563
- }
564
-
565
- function tryUpdateSpanName(
566
- activeSpan,
567
- currentSpanName,
568
- newName,
569
- newSource,
570
- ) {
571
- // Check if the new name contains React Router parameter syntax (/:param/)
572
- const isReactRouterParam = /\/:[a-zA-Z0-9_]+/.test(newName);
573
- const isNewNameParameterized = newName !== currentSpanName && isReactRouterParam;
574
- if (isNewNameParameterized) {
575
- activeSpan.updateName(newName);
576
- activeSpan.setAttribute(core.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, newSource );
577
- }
578
- }
579
-
580
- function isDuplicateNavigation(client, navigationKey) {
581
- const lastKey = LAST_NAVIGATION_PER_CLIENT.get(client);
582
- return lastKey === navigationKey;
583
- }
584
-
585
- function createNavigationSpan(opts
586
-
587
- ) {
588
- const { client, name, source, version, location, routes, basename, allRoutes, navigationKey } = opts;
589
-
590
- const navigationSpan = browser.startBrowserTracingNavigationSpan(client, {
591
- name,
592
- attributes: {
593
- [core.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source ,
594
- [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
595
- [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`,
596
- },
597
- });
598
-
599
- if (navigationSpan) {
600
- LAST_NAVIGATION_PER_CLIENT.set(client, navigationKey);
601
- patchNavigationSpanEnd(navigationSpan, location, routes, basename, allRoutes);
602
-
603
- const unsubscribe = client.on('spanEnd', endedSpan => {
604
- if (endedSpan === navigationSpan) {
605
- // Clear key only if it's still our key (handles overlapping navigations)
606
- const lastKey = LAST_NAVIGATION_PER_CLIENT.get(client);
607
- if (lastKey === navigationKey) {
608
- LAST_NAVIGATION_PER_CLIENT.delete(client);
609
- }
610
- unsubscribe(); // Prevent memory leak
611
- }
612
- });
613
- }
614
-
615
- return navigationSpan;
616
- }
617
-
686
+ // eslint-disable-next-line complexity
618
687
  function handleNavigation(opts
619
688
 
620
689
  ) {
@@ -640,33 +709,84 @@ function handleNavigation(opts
640
709
  basename,
641
710
  );
642
711
 
643
- const currentNavigationKey = getNavigationKey(location);
644
- const isNavDuplicate = isDuplicateNavigation(client, currentNavigationKey);
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
+ }
645
748
 
646
- if (isNavDuplicate) {
647
- // Cross-usage duplicate - update existing span name if better
648
- const activeSpan = core.getActiveSpan();
649
- const spanJson = activeSpan && core.spanToJSON(activeSpan);
650
- const isAlreadyInNavigationSpan = spanJson?.op === 'navigation';
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
766
+ attributes: {
767
+ [core.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
768
+ [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
769
+ [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`,
770
+ },
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
+ }
651
777
 
652
- if (isAlreadyInNavigationSpan && activeSpan) {
653
- tryUpdateSpanName(activeSpan, spanJson?.description, name, source);
654
- }
655
- } else {
656
- // Not a cross-usage duplicate - create new span
657
- // This handles: different routes, same route with different params (/user/2 → /user/3)
658
- // startBrowserTracingNavigationSpan will end any active navigation span
659
- createNavigationSpan({
660
- client,
661
- name,
662
- source,
663
- version,
664
- location,
665
- routes,
666
- basename,
667
- allRoutes,
668
- navigationKey: currentNavigationKey,
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,
669
785
  });
786
+ patchSpanEnd(navigationSpan, location, routes, basename, allRoutes, 'navigation');
787
+ } else {
788
+ // If no span was created, remove the placeholder
789
+ activeNavigationSpans.delete(client);
670
790
  }
671
791
  }
672
792
  }
@@ -730,8 +850,90 @@ function updatePageloadTransaction({
730
850
  activeRootSpan.setAttribute(core.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
731
851
 
732
852
  // Patch span.end() to ensure we update the name one last time before the span is sent
733
- patchPageloadSpanEnd(activeRootSpan, location, routes, basename, allRoutes);
853
+ patchSpanEnd(activeRootSpan, location, routes, basename, allRoutes, 'pageload');
854
+ }
855
+ }
856
+ }
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;
734
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}`);
735
937
  }
736
938
  }
737
939
 
@@ -754,71 +956,93 @@ function patchSpanEnd(
754
956
  return;
755
957
  }
756
958
 
959
+ // Use the passed route context, or fall back to global Set
960
+ const allRoutesSet = _allRoutes ? new Set(_allRoutes) : allRoutes;
961
+
757
962
  const originalEnd = span.end.bind(span);
963
+ let endCalled = false;
758
964
 
759
965
  span.end = function patchedEnd(...args) {
760
- try {
761
- // Only update if the span source is not already 'route' (i.e., it hasn't been parameterized yet)
762
- const spanJson = core.spanToJSON(span);
763
- const currentSource = spanJson.data?.[core.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
764
- if (currentSource !== 'route') {
765
- // Last chance to update the transaction name with the latest route info
766
- // Use the live global allRoutes Set to include any lazy routes loaded after patching
767
- const currentAllRoutes = Array.from(allRoutes);
768
- const branches = _matchRoutes(
769
- currentAllRoutes.length > 0 ? currentAllRoutes : routes,
770
- location,
771
- basename,
772
- ) ;
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
+ }
773
1008
 
774
- if (branches) {
775
- 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,
776
1022
  location,
777
- currentAllRoutes.length > 0 ? currentAllRoutes : routes,
778
- currentAllRoutes.length > 0 ? currentAllRoutes : routes,
779
- branches,
1023
+ routes,
780
1024
  basename,
1025
+ spanType,
1026
+ allRoutesSet,
781
1027
  );
782
-
783
- // Only update if we have a valid name
784
- if (name && (spanType === 'pageload' || !spanJson.timestamp)) {
785
- span.updateName(name);
786
- span.setAttribute(core.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
787
- }
788
- }
789
- }
790
- } catch (error) {
791
- // Silently catch errors to ensure span.end() is always called
792
- 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;
793
1036
  }
794
1037
 
795
- return originalEnd(...args);
1038
+ tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutesSet);
1039
+ cleanupNavigationSpan();
1040
+ originalEnd(endTimestamp);
796
1041
  };
797
1042
 
798
- // Mark this span as having its end() method patched to prevent duplicate patching
799
1043
  core.addNonEnumerableProperty(span , patchedPropertyName, true);
800
1044
  }
801
1045
 
802
- function patchPageloadSpanEnd(
803
- span,
804
- location,
805
- routes,
806
- basename,
807
- _allRoutes,
808
- ) {
809
- patchSpanEnd(span, location, routes, basename, _allRoutes, 'pageload');
810
- }
811
-
812
- function patchNavigationSpanEnd(
813
- span,
814
- location,
815
- routes,
816
- basename,
817
- _allRoutes,
818
- ) {
819
- patchSpanEnd(span, location, routes, basename, _allRoutes, 'navigation');
820
- }
821
-
822
1046
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
823
1047
  function createV6CompatibleWithSentryReactRouterRouting(
824
1048
  Routes,
@@ -854,11 +1078,13 @@ function createV6CompatibleWithSentryReactRouterRouting(
854
1078
  });
855
1079
  isMountRenderPass.current = false;
856
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.
857
1084
  handleNavigation({ location, routes, navigationType, version, allRoutes: Array.from(allRoutes) });
858
1085
  }
859
1086
  },
860
- // `props.children` is purposely not included in the dependency array, because we do not want to re-run this effect
861
- // 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
862
1088
  [location, navigationType],
863
1089
  );
864
1090
 
@@ -891,6 +1117,7 @@ function getActiveRootSpan() {
891
1117
  exports.addResolvedRoutesToParent = addResolvedRoutesToParent;
892
1118
  exports.addRoutesToAllRoutes = addRoutesToAllRoutes;
893
1119
  exports.allRoutes = allRoutes;
1120
+ exports.computeLocationKey = computeLocationKey;
894
1121
  exports.createReactRouterV6CompatibleTracingIntegration = createReactRouterV6CompatibleTracingIntegration;
895
1122
  exports.createV6CompatibleWithSentryReactRouterRouting = createV6CompatibleWithSentryReactRouterRouting;
896
1123
  exports.createV6CompatibleWrapCreateBrowserRouter = createV6CompatibleWrapCreateBrowserRouter;
@@ -898,5 +1125,6 @@ exports.createV6CompatibleWrapCreateMemoryRouter = createV6CompatibleWrapCreateM
898
1125
  exports.createV6CompatibleWrapUseRoutes = createV6CompatibleWrapUseRoutes;
899
1126
  exports.handleNavigation = handleNavigation;
900
1127
  exports.processResolvedRoutes = processResolvedRoutes;
1128
+ exports.shouldSkipNavigation = shouldSkipNavigation;
901
1129
  exports.updateNavigationSpan = updateNavigationSpan;
902
1130
  //# sourceMappingURL=instrumentation.js.map