Skip to content

Commit 33c2c81

Browse files
committed
Do not skip updating the previous span
1 parent 6253664 commit 33c2c81

File tree

5 files changed

+191
-88
lines changed

5 files changed

+191
-88
lines changed

packages/react/src/reactrouter-compat-utils/instrumentation.tsx

Lines changed: 38 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,8 @@ import type { Client, Integration, Span } from '@sentry/core';
1212
import {
1313
addNonEnumerableProperty,
1414
debug,
15-
getActiveSpan,
1615
getClient,
1716
getCurrentScope,
18-
getRootSpan,
1917
SEMANTIC_ATTRIBUTE_SENTRY_OP,
2018
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
2119
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
@@ -41,7 +39,14 @@ import type {
4139
UseRoutes,
4240
} from '../types';
4341
import { checkRouteForAsyncHandler } from './lazy-routes';
44-
import { initializeRouterUtils, resolveRouteNameAndSource, transactionNameHasWildcard } from './utils';
42+
import {
43+
clearNavigationContext,
44+
getActiveRootSpan,
45+
initializeRouterUtils,
46+
resolveRouteNameAndSource,
47+
setNavigationContext,
48+
transactionNameHasWildcard,
49+
} from './utils';
4550

4651
let _useEffect: UseEffect;
4752
let _useLocation: UseLocation;
@@ -230,11 +235,14 @@ function trackLazyRouteLoad(span: Span, promise: Promise<unknown>): void {
230235

231236
/**
232237
* Processes resolved routes by adding them to allRoutes and checking for nested async handlers.
238+
* When capturedSpan is provided, updates that specific span instead of the current active span.
239+
* This prevents race conditions where a lazy handler resolves after the user has navigated away.
233240
*/
234241
export function processResolvedRoutes(
235242
resolvedRoutes: RouteObject[],
236243
parentRoute?: RouteObject,
237244
currentLocation: Location | null = null,
245+
capturedSpan?: Span,
238246
): void {
239247
resolvedRoutes.forEach(child => {
240248
allRoutes.add(child);
@@ -249,56 +257,44 @@ export function processResolvedRoutes(
249257
addResolvedRoutesToParent(resolvedRoutes, parentRoute);
250258
}
251259

252-
// After processing lazy routes, check if we need to update an active transaction
253-
const activeRootSpan = getActiveRootSpan();
254-
if (activeRootSpan) {
255-
const spanOp = spanToJSON(activeRootSpan).op;
260+
// Use captured span if provided, otherwise fall back to current active span
261+
const targetSpan = capturedSpan ?? getActiveRootSpan();
262+
if (targetSpan) {
263+
const spanJson = spanToJSON(targetSpan);
264+
265+
// Skip update if span has already ended (timestamp is set when span.end() is called)
266+
if (spanJson.timestamp) {
267+
DEBUG_BUILD && debug.warn('[React Router] Lazy handler resolved after span ended - skipping update');
268+
return;
269+
}
270+
271+
const spanOp = spanJson.op;
256272

257-
// Try to use the provided location first, then fall back to global window location if needed
273+
// Use captured location for route matching (ensures we match against the correct route)
274+
// Fall back to window.location only if no captured location and no captured span
275+
// (i.e., this is not from an async handler)
258276
let location = currentLocation;
259-
if (!location) {
277+
if (!location && !capturedSpan) {
260278
if (typeof WINDOW !== 'undefined') {
261279
const globalLocation = WINDOW.location;
262-
if (globalLocation) {
280+
if (globalLocation?.pathname) {
263281
location = { pathname: globalLocation.pathname };
264282
}
265283
}
266284
}
267285

268-
// If the user navigated away before this lazy handler resolved, skip updating the span.
269-
if (currentLocation && typeof WINDOW !== 'undefined') {
270-
try {
271-
const windowLocation = WINDOW.location;
272-
if (windowLocation) {
273-
const capturedKey = computeLocationKey(currentLocation);
274-
const currentKey = computeLocationKey({
275-
pathname: windowLocation.pathname,
276-
search: windowLocation.search || '',
277-
hash: windowLocation.hash || '',
278-
});
279-
280-
if (currentKey !== capturedKey) {
281-
return;
282-
}
283-
}
284-
} catch {
285-
DEBUG_BUILD && debug.warn('[React Router] Could not access window.location');
286-
return;
287-
}
288-
}
289-
290286
if (location) {
291287
if (spanOp === 'pageload') {
292288
// Re-run the pageload transaction update with the newly loaded routes
293289
updatePageloadTransaction({
294-
activeRootSpan,
290+
activeRootSpan: targetSpan,
295291
location: { pathname: location.pathname },
296292
routes: Array.from(allRoutes),
297293
allRoutes: Array.from(allRoutes),
298294
});
299295
} else if (spanOp === 'navigation') {
300296
// For navigation spans, update the name with the newly loaded routes
301-
updateNavigationSpan(activeRootSpan, location, Array.from(allRoutes), false, _matchRoutes);
297+
updateNavigationSpan(targetSpan, location, Array.from(allRoutes), false, _matchRoutes);
302298
}
303299
}
304300
}
@@ -750,7 +746,14 @@ function wrapPatchRoutesOnNavigation(
750746
}
751747

752748
const lazyLoadPromise = (async () => {
753-
const result = await originalPatchRoutes(args);
749+
// Set context so async handlers can access correct targetPath and span
750+
setNavigationContext(targetPath, activeRootSpan);
751+
let result;
752+
try {
753+
result = await originalPatchRoutes(args);
754+
} finally {
755+
clearNavigationContext();
756+
}
754757

755758
const currentActiveRootSpan = getActiveRootSpan();
756759
if (currentActiveRootSpan && (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation') {
@@ -1206,17 +1209,3 @@ export function createV6CompatibleWithSentryReactRouterRouting<P extends Record<
12061209
// will break advanced type inference done by react router params
12071210
return SentryRoutes;
12081211
}
1209-
1210-
function getActiveRootSpan(): Span | undefined {
1211-
const span = getActiveSpan();
1212-
const rootSpan = span ? getRootSpan(span) : undefined;
1213-
1214-
if (!rootSpan) {
1215-
return undefined;
1216-
}
1217-
1218-
const op = spanToJSON(rootSpan).op;
1219-
1220-
// Only use this root span if it is a pageload or navigation span
1221-
return op === 'navigation' || op === 'pageload' ? rootSpan : undefined;
1222-
}

packages/react/src/reactrouter-compat-utils/lazy-routes.tsx

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,26 @@
11
import { WINDOW } from '@sentry/browser';
2+
import type { Span } from '@sentry/core';
23
import { addNonEnumerableProperty, debug, isThenable } from '@sentry/core';
34
import { DEBUG_BUILD } from '../debug-build';
45
import type { Location, RouteObject } from '../types';
6+
import { getActiveRootSpan, getNavigationContext } from './utils';
57

68
/**
7-
* Captures the current location at the time of invocation.
9+
* Captures location at invocation time. Prefers navigation context over window.location
10+
* since window.location hasn't updated yet when async handlers are invoked.
811
*/
912
function captureCurrentLocation(): Location | null {
13+
const navContext = getNavigationContext();
14+
if (navContext) {
15+
return {
16+
pathname: navContext.targetPath,
17+
search: '',
18+
hash: '',
19+
state: null,
20+
key: 'default',
21+
};
22+
}
23+
1024
if (typeof WINDOW !== 'undefined') {
1125
try {
1226
const windowLocation = WINDOW.location;
@@ -26,20 +40,47 @@ function captureCurrentLocation(): Location | null {
2640
return null;
2741
}
2842

43+
/**
44+
* Captures the active span at invocation time. Prefers navigation context span
45+
* to ensure we update the correct span even if another navigation starts.
46+
*/
47+
function captureActiveSpan(): Span | undefined {
48+
const navContext = getNavigationContext();
49+
if (navContext) {
50+
return navContext.span;
51+
}
52+
return getActiveRootSpan();
53+
}
54+
2955
/**
3056
* Creates a proxy wrapper for an async handler function.
57+
* Captures both the location and the active span at invocation time to ensure
58+
* the correct span is updated when the handler resolves.
3159
*/
3260
export function createAsyncHandlerProxy(
3361
originalFunction: (...args: unknown[]) => unknown,
3462
route: RouteObject,
3563
handlerKey: string,
36-
processResolvedRoutes: (resolvedRoutes: RouteObject[], parentRoute?: RouteObject, currentLocation?: Location) => void,
64+
processResolvedRoutes: (
65+
resolvedRoutes: RouteObject[],
66+
parentRoute?: RouteObject,
67+
currentLocation?: Location,
68+
capturedSpan?: Span,
69+
) => void,
3770
): (...args: unknown[]) => unknown {
3871
const proxy = new Proxy(originalFunction, {
3972
apply(target: (...args: unknown[]) => unknown, thisArg, argArray) {
4073
const locationAtInvocation = captureCurrentLocation();
74+
const spanAtInvocation = captureActiveSpan();
4175
const result = target.apply(thisArg, argArray);
42-
handleAsyncHandlerResult(result, route, handlerKey, processResolvedRoutes, locationAtInvocation);
76+
handleAsyncHandlerResult(
77+
result,
78+
route,
79+
handlerKey,
80+
processResolvedRoutes,
81+
locationAtInvocation,
82+
spanAtInvocation,
83+
);
4384
return result;
4485
},
4586
});
@@ -51,26 +92,33 @@ export function createAsyncHandlerProxy(
5192

5293
/**
5394
* Handles the result of an async handler function call.
95+
* Passes the captured span through to ensure the correct span is updated.
5496
*/
5597
export function handleAsyncHandlerResult(
5698
result: unknown,
5799
route: RouteObject,
58100
handlerKey: string,
59-
processResolvedRoutes: (resolvedRoutes: RouteObject[], parentRoute?: RouteObject, currentLocation?: Location) => void,
101+
processResolvedRoutes: (
102+
resolvedRoutes: RouteObject[],
103+
parentRoute?: RouteObject,
104+
currentLocation?: Location,
105+
capturedSpan?: Span,
106+
) => void,
60107
currentLocation: Location | null,
108+
capturedSpan: Span | undefined,
61109
): void {
62110
if (isThenable(result)) {
63111
(result as Promise<unknown>)
64112
.then((resolvedRoutes: unknown) => {
65113
if (Array.isArray(resolvedRoutes)) {
66-
processResolvedRoutes(resolvedRoutes, route, currentLocation ?? undefined);
114+
processResolvedRoutes(resolvedRoutes, route, currentLocation ?? undefined, capturedSpan);
67115
}
68116
})
69117
.catch((e: unknown) => {
70118
DEBUG_BUILD && debug.warn(`Error resolving async handler '${handlerKey}' for route`, route, e);
71119
});
72120
} else if (Array.isArray(result)) {
73-
processResolvedRoutes(result, route, currentLocation ?? undefined);
121+
processResolvedRoutes(result, route, currentLocation ?? undefined, capturedSpan);
74122
}
75123
}
76124

@@ -79,7 +127,12 @@ export function handleAsyncHandlerResult(
79127
*/
80128
export function checkRouteForAsyncHandler(
81129
route: RouteObject,
82-
processResolvedRoutes: (resolvedRoutes: RouteObject[], parentRoute?: RouteObject, currentLocation?: Location) => void,
130+
processResolvedRoutes: (
131+
resolvedRoutes: RouteObject[],
132+
parentRoute?: RouteObject,
133+
currentLocation?: Location,
134+
capturedSpan?: Span,
135+
) => void,
83136
): void {
84137
// Set up proxies for any functions in the route's handle
85138
if (route.handle && typeof route.handle === 'object') {

packages/react/src/reactrouter-compat-utils/utils.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,37 @@
1-
import type { TransactionSource } from '@sentry/core';
1+
import type { Span, TransactionSource } from '@sentry/core';
2+
import { getActiveSpan, getRootSpan, spanToJSON } from '@sentry/core';
23
import type { Location, MatchRoutes, RouteMatch, RouteObject } from '../types';
34

45
// Global variables that these utilities depend on
56
let _matchRoutes: MatchRoutes;
67
let _stripBasename: boolean = false;
78

9+
// Navigation context stack for nested/concurrent patchRoutesOnNavigation calls.
10+
// Required because window.location hasn't updated yet when handlers are invoked.
11+
// Uses a stack to handle overlapping navigations correctly (LIFO semantics).
12+
interface NavigationContext {
13+
targetPath: string;
14+
span: Span | undefined;
15+
}
16+
17+
const _navigationContextStack: NavigationContext[] = [];
18+
19+
/** Pushes a navigation context before invoking patchRoutesOnNavigation. */
20+
export function setNavigationContext(targetPath: string, span: Span | undefined): void {
21+
_navigationContextStack.push({ targetPath, span });
22+
}
23+
24+
/** Pops the most recent navigation context after patchRoutesOnNavigation completes. */
25+
export function clearNavigationContext(): void {
26+
_navigationContextStack.pop();
27+
}
28+
29+
/** Gets the current (most recent) navigation context if inside a patchRoutesOnNavigation call. */
30+
export function getNavigationContext(): NavigationContext | null {
31+
const length = _navigationContextStack.length;
32+
return length > 0 ? (_navigationContextStack[length - 1] ?? null) : null;
33+
}
34+
835
/**
936
* Initialize function to set dependencies that the router utilities need.
1037
* Must be called before using any of the exported utility functions.
@@ -273,3 +300,20 @@ export function resolveRouteNameAndSource(
273300

274301
return [name || location.pathname, source];
275302
}
303+
304+
/**
305+
* Gets the active root span if it's a pageload or navigation span.
306+
*/
307+
export function getActiveRootSpan(): Span | undefined {
308+
const span = getActiveSpan();
309+
const rootSpan = span ? getRootSpan(span) : undefined;
310+
311+
if (!rootSpan) {
312+
return undefined;
313+
}
314+
315+
const op = spanToJSON(rootSpan).op;
316+
317+
// Only use this root span if it is a pageload or navigation span
318+
return op === 'navigation' || op === 'pageload' ? rootSpan : undefined;
319+
}

packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ vi.mock('../../src/reactrouter-compat-utils/utils', () => ({
5959
transactionNameHasWildcard: vi.fn((name: string) => {
6060
return name.includes('/*') || name === '*' || name.endsWith('*');
6161
}),
62+
getActiveRootSpan: vi.fn(() => undefined),
6263
}));
6364

6465
vi.mock('../../src/reactrouter-compat-utils/lazy-routes', () => ({

0 commit comments

Comments
 (0)