Skip to content

Commit 3f0ca23

Browse files
committed
fix(react): Prevent lazy route handlers from updating wrong navigation span
1 parent 9115e19 commit 3f0ca23

5 files changed

Lines changed: 309 additions & 27 deletions

File tree

dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1228,3 +1228,134 @@ test('Query/hash navigation does not corrupt transaction name', async ({ page })
12281228
const corruptedToRoot = navigationTransactions.filter(t => t.name === '/');
12291229
expect(corruptedToRoot.length).toBe(0);
12301230
});
1231+
1232+
test('Captured navigation context is used instead of stale window.location during rapid navigation', async ({
1233+
page,
1234+
}) => {
1235+
// Validates fix for race condition where captureCurrentLocation would use stale WINDOW.location.
1236+
// Navigate to slow route, then quickly to another route before lazy handler resolves.
1237+
await page.goto('/');
1238+
1239+
const allNavigationTransactions: Array<{ name: string; traceId: string }> = [];
1240+
1241+
const collectorPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
1242+
if (transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation') {
1243+
allNavigationTransactions.push({
1244+
name: transactionEvent.transaction,
1245+
traceId: transactionEvent.contexts.trace.trace_id || '',
1246+
});
1247+
}
1248+
return allNavigationTransactions.length >= 2;
1249+
});
1250+
1251+
const slowFetchLink = page.locator('id=navigation-to-slow-fetch');
1252+
await expect(slowFetchLink).toBeVisible();
1253+
await slowFetchLink.click();
1254+
1255+
// Navigate away quickly before slow-fetch's async handler resolves
1256+
await page.waitForTimeout(50);
1257+
1258+
const anotherLink = page.locator('id=navigation-to-another');
1259+
await anotherLink.click();
1260+
1261+
await expect(page.locator('id=another-lazy-route')).toBeVisible({ timeout: 10000 });
1262+
1263+
await page.waitForTimeout(2000);
1264+
1265+
await Promise.race([
1266+
collectorPromise,
1267+
new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 3000)),
1268+
]).catch(() => {});
1269+
1270+
expect(allNavigationTransactions.length).toBeGreaterThanOrEqual(1);
1271+
1272+
// /another-lazy transaction must have correct name (not corrupted by slow-fetch handler)
1273+
const anotherLazyTransaction = allNavigationTransactions.find(
1274+
t =>
1275+
t.name.startsWith('/another-lazy/sub'),
1276+
);
1277+
expect(anotherLazyTransaction).toBeDefined();
1278+
1279+
const corruptedToRoot = allNavigationTransactions.filter(t => t.name === '/');
1280+
expect(corruptedToRoot.length).toBe(0);
1281+
1282+
if (allNavigationTransactions.length >= 2) {
1283+
const uniqueNames = new Set(allNavigationTransactions.map(t => t.name));
1284+
expect(uniqueNames.size).toBe(allNavigationTransactions.length);
1285+
}
1286+
});
1287+
1288+
test('Second navigation span is not corrupted by first slow lazy handler completing late', async ({ page }) => {
1289+
// Validates fix for race condition where slow lazy handler would update the wrong span.
1290+
// Navigate to slow route (which fetches /api/slow-data), then quickly to fast route.
1291+
// Without fix: second transaction gets wrong name and/or contains leaked spans.
1292+
1293+
await page.goto('/');
1294+
1295+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1296+
const allNavigationTransactions: Array<{ name: string; traceId: string; spans: any[] }> = [];
1297+
1298+
const collectorPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
1299+
if (transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation') {
1300+
allNavigationTransactions.push({
1301+
name: transactionEvent.transaction,
1302+
traceId: transactionEvent.contexts.trace.trace_id || '',
1303+
spans: transactionEvent.spans || [],
1304+
});
1305+
}
1306+
return false;
1307+
});
1308+
1309+
// Navigate to slow-fetch (500ms lazy delay, fetches /api/slow-data)
1310+
const slowFetchLink = page.locator('id=navigation-to-slow-fetch');
1311+
await expect(slowFetchLink).toBeVisible();
1312+
await slowFetchLink.click();
1313+
1314+
// Wait 150ms (before 500ms lazy loading completes), then navigate away
1315+
await page.waitForTimeout(150);
1316+
1317+
const anotherLink = page.locator('id=navigation-to-another');
1318+
await anotherLink.click();
1319+
1320+
await expect(page.locator('id=another-lazy-route')).toBeVisible({ timeout: 10000 });
1321+
1322+
// Wait for slow-fetch lazy handler to complete and transactions to be sent
1323+
await page.waitForTimeout(2000);
1324+
1325+
await Promise.race([
1326+
collectorPromise,
1327+
new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 3000)),
1328+
]).catch(() => {});
1329+
1330+
expect(allNavigationTransactions.length).toBeGreaterThanOrEqual(1);
1331+
1332+
// /another-lazy transaction must have correct name, not "/slow-fetch/:id"
1333+
const anotherLazyTransaction = allNavigationTransactions.find(
1334+
t =>
1335+
t.name.startsWith('/another-lazy/sub'),
1336+
);
1337+
expect(anotherLazyTransaction).toBeDefined();
1338+
1339+
// Key assertion 2: /another-lazy transaction must NOT contain spans from /slow-fetch route
1340+
// The /api/slow-data fetch is triggered by the slow-fetch route's lazy loading
1341+
if (anotherLazyTransaction) {
1342+
const leakedSpans = anotherLazyTransaction.spans.filter(
1343+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1344+
(span: any) => span.description?.includes('slow-data') || span.data?.url?.includes('slow-data'),
1345+
);
1346+
expect(leakedSpans.length).toBe(0);
1347+
}
1348+
1349+
// Key assertion 3: If slow-fetch transaction exists, verify it has the correct name
1350+
// (not corrupted to /another-lazy)
1351+
const slowFetchTransaction = allNavigationTransactions.find(t => t.name.includes('slow-fetch'));
1352+
if (slowFetchTransaction) {
1353+
expect(slowFetchTransaction.name).toMatch(/\/slow-fetch/);
1354+
// Verify slow-fetch transaction doesn't contain spans that belong to /another-lazy
1355+
const wrongSpans = slowFetchTransaction.spans.filter(
1356+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1357+
(span: any) => span.description?.includes('another-lazy') || span.data?.url?.includes('another-lazy'),
1358+
);
1359+
expect(wrongSpans.length).toBe(0);
1360+
}
1361+
});

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

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -730,15 +730,20 @@ function wrapPatchRoutesOnNavigation(
730730
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
731731
(args as any).patch = (routeId: string, children: RouteObject[]) => {
732732
addRoutesToAllRoutes(children);
733-
const currentActiveRootSpan = getActiveRootSpan();
734-
// Only update if we have a valid targetPath (patchRoutesOnNavigation can be called without path)
733+
// Use the captured activeRootSpan instead of getActiveRootSpan() to avoid race conditions
734+
// where user navigates away during lazy route loading and we'd update the wrong span
735+
const spanJson = activeRootSpan ? spanToJSON(activeRootSpan) : undefined;
736+
// Only update if we have a valid targetPath (patchRoutesOnNavigation can be called without path),
737+
// the captured span exists, hasn't ended, and is a navigation span
735738
if (
736739
targetPath &&
737-
currentActiveRootSpan &&
738-
(spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation'
740+
activeRootSpan &&
741+
spanJson &&
742+
!spanJson.timestamp && // Span hasn't ended yet
743+
spanJson.op === 'navigation'
739744
) {
740745
updateNavigationSpan(
741-
currentActiveRootSpan,
746+
activeRootSpan,
742747
{ pathname: targetPath, search: '', hash: '', state: null, key: 'default' },
743748
Array.from(allRoutes),
744749
true,
@@ -760,13 +765,22 @@ function wrapPatchRoutesOnNavigation(
760765
clearNavigationContext(contextToken);
761766
}
762767

763-
const currentActiveRootSpan = getActiveRootSpan();
764-
if (currentActiveRootSpan && (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation') {
765-
const pathname = isMemoryRouter ? targetPath : targetPath || WINDOW.location?.pathname;
768+
// Use the captured activeRootSpan instead of getActiveRootSpan() to avoid race conditions
769+
// where user navigates away during lazy route loading and we'd update the wrong span
770+
const spanJson = activeRootSpan ? spanToJSON(activeRootSpan) : undefined;
771+
if (
772+
activeRootSpan &&
773+
spanJson &&
774+
!spanJson.timestamp && // Span hasn't ended yet
775+
spanJson.op === 'navigation'
776+
) {
777+
// Use targetPath consistently - don't fall back to WINDOW.location which may have changed
778+
// if the user navigated away during async loading
779+
const pathname = targetPath;
766780

767781
if (pathname) {
768782
updateNavigationSpan(
769-
currentActiveRootSpan,
783+
activeRootSpan,
770784
{ pathname, search: '', hash: '', state: null, key: 'default' },
771785
Array.from(allRoutes),
772786
false,

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

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,28 @@ import { getActiveRootSpan, getNavigationContext } from './utils';
88
/**
99
* Captures location at invocation time. Prefers navigation context over window.location
1010
* since window.location hasn't updated yet when async handlers are invoked.
11+
*
12+
* When inside a patchRoutesOnNavigation call, uses the captured targetPath. If targetPath
13+
* is undefined (patchRoutesOnNavigation can be invoked without a path argument), returns
14+
* null rather than falling back to WINDOW.location which could be stale/wrong after the
15+
* user navigated away during async loading. Returning null causes the span name update
16+
* to be skipped, which is safer than using incorrect location data.
1117
*/
1218
function captureCurrentLocation(): Location | null {
1319
const navContext = getNavigationContext();
14-
// Only use navigation context if targetPath is defined (it can be undefined
15-
// if patchRoutesOnNavigation was invoked without a path argument)
16-
if (navContext?.targetPath) {
17-
return {
18-
pathname: navContext.targetPath,
19-
search: '',
20-
hash: '',
21-
state: null,
22-
key: 'default',
23-
};
20+
21+
if (navContext) {
22+
if (navContext.targetPath) {
23+
return {
24+
pathname: navContext.targetPath,
25+
search: '',
26+
hash: '',
27+
state: null,
28+
key: 'default',
29+
};
30+
}
31+
// Don't fall back to potentially stale WINDOW.location
32+
return null;
2433
}
2534

2635
if (typeof WINDOW !== 'undefined') {

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1309,4 +1309,56 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => {
13091309
}
13101310
});
13111311
});
1312+
1313+
describe('wrapPatchRoutesOnNavigation race condition fix', () => {
1314+
it('should use captured span instead of current active span in args.patch callback', () => {
1315+
const endedSpanJson = {
1316+
op: 'navigation',
1317+
timestamp: 1234567890, // Span has ended
1318+
};
1319+
1320+
vi.mocked(spanToJSON).mockReturnValue(endedSpanJson as any);
1321+
1322+
const endedSpan = {
1323+
updateName: vi.fn(),
1324+
setAttribute: vi.fn(),
1325+
} as unknown as Span;
1326+
1327+
updateNavigationSpan(
1328+
endedSpan,
1329+
{ pathname: '/test', search: '', hash: '', state: null, key: 'test' },
1330+
[],
1331+
false,
1332+
vi.fn(() => []),
1333+
);
1334+
1335+
// eslint-disable-next-line @typescript-eslint/unbound-method
1336+
expect(endedSpan.updateName).not.toHaveBeenCalled();
1337+
});
1338+
1339+
it('should not fall back to WINDOW.location.pathname after async operations', () => {
1340+
const validSpanJson = {
1341+
op: 'navigation',
1342+
timestamp: undefined, // Span hasn't ended
1343+
};
1344+
1345+
vi.mocked(spanToJSON).mockReturnValue(validSpanJson as any);
1346+
1347+
const validSpan = {
1348+
updateName: vi.fn(),
1349+
setAttribute: vi.fn(),
1350+
} as unknown as Span;
1351+
1352+
updateNavigationSpan(
1353+
validSpan,
1354+
{ pathname: '/captured/path', search: '', hash: '', state: null, key: 'test' },
1355+
[],
1356+
false,
1357+
vi.fn(() => []),
1358+
);
1359+
1360+
// eslint-disable-next-line @typescript-eslint/unbound-method
1361+
expect(validSpan.updateName).toHaveBeenCalled();
1362+
});
1363+
});
13121364
});

0 commit comments

Comments
 (0)