Skip to content

Commit a2c6253

Browse files
authored
fix(react): Prevent lazy route handlers from updating wrong navigation span (#18898)
Following up on #18346 Fixes a remaining race condition where the `args.patch` callback and completion handler in `wrapPatchRoutesOnNavigation` were still calling `getActiveRootSpan()` at resolution time instead of using the captured span. This caused wrong span updates when users navigated away during lazy route loading. The fix uses the captured `activeRootSpan` consistently, adds a `!spanJson.timestamp` check to skip updates on ended spans, and removes the `WINDOW.location` fallback. In `captureCurrentLocation`, returns `null` when navigation context exists but `targetPath` is `undefined`, instead of falling back to stale `WINDOW.location`. Also added E2E tests that simulate rapid navigation scenarios where a slow lazy route handler completes after the user navigated elsewhere.
1 parent 62bf7cc commit a2c6253

File tree

5 files changed

+321
-51
lines changed

5 files changed

+321
-51
lines changed

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

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1308,3 +1308,128 @@ test('Slow lazy route navigation with early span end still gets parameterized ro
13081308
expect(event.contexts?.trace?.op).toBe('navigation');
13091309
expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route');
13101310
});
1311+
1312+
test('Captured navigation context is used instead of stale window.location during rapid navigation', async ({
1313+
page,
1314+
}) => {
1315+
// Validates fix for race condition where captureCurrentLocation would use stale WINDOW.location.
1316+
// Navigate to slow route, then quickly to another route before lazy handler resolves.
1317+
await page.goto('/');
1318+
1319+
const allNavigationTransactions: Array<{ name: string; traceId: string }> = [];
1320+
1321+
const collectorPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
1322+
if (transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation') {
1323+
allNavigationTransactions.push({
1324+
name: transactionEvent.transaction,
1325+
traceId: transactionEvent.contexts.trace.trace_id || '',
1326+
});
1327+
}
1328+
return allNavigationTransactions.length >= 2;
1329+
});
1330+
1331+
const slowFetchLink = page.locator('id=navigation-to-slow-fetch');
1332+
await expect(slowFetchLink).toBeVisible();
1333+
await slowFetchLink.click();
1334+
1335+
// Navigate away quickly before slow-fetch's async handler resolves
1336+
await page.waitForTimeout(50);
1337+
1338+
const anotherLink = page.locator('id=navigation-to-another');
1339+
await anotherLink.click();
1340+
1341+
await expect(page.locator('id=another-lazy-route')).toBeVisible({ timeout: 10000 });
1342+
1343+
await page.waitForTimeout(2000);
1344+
1345+
await Promise.race([
1346+
collectorPromise,
1347+
new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 3000)),
1348+
]).catch(() => {});
1349+
1350+
expect(allNavigationTransactions.length).toBeGreaterThanOrEqual(1);
1351+
1352+
// /another-lazy transaction must have correct name (not corrupted by slow-fetch handler)
1353+
const anotherLazyTransaction = allNavigationTransactions.find(t => t.name.startsWith('/another-lazy/sub'));
1354+
expect(anotherLazyTransaction).toBeDefined();
1355+
1356+
const corruptedToRoot = allNavigationTransactions.filter(t => t.name === '/');
1357+
expect(corruptedToRoot.length).toBe(0);
1358+
1359+
if (allNavigationTransactions.length >= 2) {
1360+
const uniqueNames = new Set(allNavigationTransactions.map(t => t.name));
1361+
expect(uniqueNames.size).toBe(allNavigationTransactions.length);
1362+
}
1363+
});
1364+
1365+
test('Second navigation span is not corrupted by first slow lazy handler completing late', async ({ page }) => {
1366+
// Validates fix for race condition where slow lazy handler would update the wrong span.
1367+
// Navigate to slow route (which fetches /api/slow-data), then quickly to fast route.
1368+
// Without fix: second transaction gets wrong name and/or contains leaked spans.
1369+
1370+
await page.goto('/');
1371+
1372+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1373+
const allNavigationTransactions: Array<{ name: string; traceId: string; spans: any[] }> = [];
1374+
1375+
const collectorPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
1376+
if (transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation') {
1377+
allNavigationTransactions.push({
1378+
name: transactionEvent.transaction,
1379+
traceId: transactionEvent.contexts.trace.trace_id || '',
1380+
spans: transactionEvent.spans || [],
1381+
});
1382+
}
1383+
return false;
1384+
});
1385+
1386+
// Navigate to slow-fetch (500ms lazy delay, fetches /api/slow-data)
1387+
const slowFetchLink = page.locator('id=navigation-to-slow-fetch');
1388+
await expect(slowFetchLink).toBeVisible();
1389+
await slowFetchLink.click();
1390+
1391+
// Wait 150ms (before 500ms lazy loading completes), then navigate away
1392+
await page.waitForTimeout(150);
1393+
1394+
const anotherLink = page.locator('id=navigation-to-another');
1395+
await anotherLink.click();
1396+
1397+
await expect(page.locator('id=another-lazy-route')).toBeVisible({ timeout: 10000 });
1398+
1399+
// Wait for slow-fetch lazy handler to complete and transactions to be sent
1400+
await page.waitForTimeout(2000);
1401+
1402+
await Promise.race([
1403+
collectorPromise,
1404+
new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 3000)),
1405+
]).catch(() => {});
1406+
1407+
expect(allNavigationTransactions.length).toBeGreaterThanOrEqual(1);
1408+
1409+
// /another-lazy transaction must have correct name, not "/slow-fetch/:id"
1410+
const anotherLazyTransaction = allNavigationTransactions.find(t => t.name.startsWith('/another-lazy/sub'));
1411+
expect(anotherLazyTransaction).toBeDefined();
1412+
1413+
// Key assertion 2: /another-lazy transaction must NOT contain spans from /slow-fetch route
1414+
// The /api/slow-data fetch is triggered by the slow-fetch route's lazy loading
1415+
if (anotherLazyTransaction) {
1416+
const leakedSpans = anotherLazyTransaction.spans.filter(
1417+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1418+
(span: any) => span.description?.includes('slow-data') || span.data?.url?.includes('slow-data'),
1419+
);
1420+
expect(leakedSpans.length).toBe(0);
1421+
}
1422+
1423+
// Key assertion 3: If slow-fetch transaction exists, verify it has the correct name
1424+
// (not corrupted to /another-lazy)
1425+
const slowFetchTransaction = allNavigationTransactions.find(t => t.name.includes('slow-fetch'));
1426+
if (slowFetchTransaction) {
1427+
expect(slowFetchTransaction.name).toMatch(/\/slow-fetch/);
1428+
// Verify slow-fetch transaction doesn't contain spans that belong to /another-lazy
1429+
const wrongSpans = slowFetchTransaction.spans.filter(
1430+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1431+
(span: any) => span.description?.includes('another-lazy') || span.data?.url?.includes('another-lazy'),
1432+
);
1433+
expect(wrongSpans.length).toBe(0);
1434+
}
1435+
});

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

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -772,33 +772,6 @@ export function createV6CompatibleWrapUseRoutes(origUseRoutes: UseRoutes, versio
772772
return <SentryRoutes routes={routes} locationArg={locationArg} />;
773773
};
774774
}
775-
776-
/**
777-
* Helper to update the current span (navigation or pageload) with lazy-loaded route information.
778-
* Reduces code duplication in patchRoutesOnNavigation wrapper.
779-
*/
780-
function updateSpanWithLazyRoutes(pathname: string, forceUpdate: boolean): void {
781-
const currentActiveRootSpan = getActiveRootSpan();
782-
if (!currentActiveRootSpan) {
783-
return;
784-
}
785-
786-
const spanOp = (spanToJSON(currentActiveRootSpan) as { op?: string }).op;
787-
const location = { pathname, search: '', hash: '', state: null, key: 'default' };
788-
const routesArray = Array.from(allRoutes);
789-
790-
if (spanOp === 'navigation') {
791-
updateNavigationSpan(currentActiveRootSpan, location, routesArray, forceUpdate, _matchRoutes);
792-
} else if (spanOp === 'pageload') {
793-
updatePageloadTransaction({
794-
activeRootSpan: currentActiveRootSpan,
795-
location,
796-
routes: routesArray,
797-
allRoutes: routesArray,
798-
});
799-
}
800-
}
801-
802775
function wrapPatchRoutesOnNavigation(
803776
opts: Record<string, unknown> | undefined,
804777
isMemoryRouter = false,
@@ -853,9 +826,25 @@ function wrapPatchRoutesOnNavigation(
853826
}
854827
}
855828

856-
// Only update if we have a valid targetPath (patchRoutesOnNavigation can be called without path)
857-
if (targetPath) {
858-
updateSpanWithLazyRoutes(targetPath, true);
829+
// Use the captured activeRootSpan instead of getActiveRootSpan() to avoid race conditions
830+
// where user navigates away during lazy route loading and we'd update the wrong span
831+
const spanJson = activeRootSpan ? spanToJSON(activeRootSpan) : undefined;
832+
// Only update if we have a valid targetPath (patchRoutesOnNavigation can be called without path),
833+
// the captured span exists, hasn't ended, and is a navigation span
834+
if (
835+
targetPath &&
836+
activeRootSpan &&
837+
spanJson &&
838+
!spanJson.timestamp && // Span hasn't ended yet
839+
spanJson.op === 'navigation'
840+
) {
841+
updateNavigationSpan(
842+
activeRootSpan,
843+
{ pathname: targetPath, search: '', hash: '', state: null, key: 'default' },
844+
Array.from(allRoutes),
845+
true,
846+
_matchRoutes,
847+
);
859848
}
860849
return originalPatch(routeId, children);
861850
};
@@ -877,9 +866,28 @@ function wrapPatchRoutesOnNavigation(
877866
}
878867
}
879868

880-
const pathname = isMemoryRouter ? targetPath : targetPath || WINDOW.location?.pathname;
881-
if (pathname) {
882-
updateSpanWithLazyRoutes(pathname, false);
869+
// Use the captured activeRootSpan instead of getActiveRootSpan() to avoid race conditions
870+
// where user navigates away during lazy route loading and we'd update the wrong span
871+
const spanJson = activeRootSpan ? spanToJSON(activeRootSpan) : undefined;
872+
if (
873+
activeRootSpan &&
874+
spanJson &&
875+
!spanJson.timestamp && // Span hasn't ended yet
876+
spanJson.op === 'navigation'
877+
) {
878+
// Use targetPath consistently - don't fall back to WINDOW.location which may have changed
879+
// if the user navigated away during async loading
880+
const pathname = targetPath;
881+
882+
if (pathname) {
883+
updateNavigationSpan(
884+
activeRootSpan,
885+
{ pathname, search: '', hash: '', state: null, key: 'default' },
886+
Array.from(allRoutes),
887+
false,
888+
_matchRoutes,
889+
);
890+
}
883891
}
884892

885893
return result;

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
@@ -1397,4 +1397,56 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => {
13971397
expect(allRoutesArray).toContain(lazyLoadedRoutes[0]);
13981398
});
13991399
});
1400+
1401+
describe('wrapPatchRoutesOnNavigation race condition fix', () => {
1402+
it('should use captured span instead of current active span in args.patch callback', () => {
1403+
const endedSpanJson = {
1404+
op: 'navigation',
1405+
timestamp: 1234567890, // Span has ended
1406+
};
1407+
1408+
vi.mocked(spanToJSON).mockReturnValue(endedSpanJson as any);
1409+
1410+
const endedSpan = {
1411+
updateName: vi.fn(),
1412+
setAttribute: vi.fn(),
1413+
} as unknown as Span;
1414+
1415+
updateNavigationSpan(
1416+
endedSpan,
1417+
{ pathname: '/test', search: '', hash: '', state: null, key: 'test' },
1418+
[],
1419+
false,
1420+
vi.fn(() => []),
1421+
);
1422+
1423+
// eslint-disable-next-line @typescript-eslint/unbound-method
1424+
expect(endedSpan.updateName).not.toHaveBeenCalled();
1425+
});
1426+
1427+
it('should not fall back to WINDOW.location.pathname after async operations', () => {
1428+
const validSpanJson = {
1429+
op: 'navigation',
1430+
timestamp: undefined, // Span hasn't ended
1431+
};
1432+
1433+
vi.mocked(spanToJSON).mockReturnValue(validSpanJson as any);
1434+
1435+
const validSpan = {
1436+
updateName: vi.fn(),
1437+
setAttribute: vi.fn(),
1438+
} as unknown as Span;
1439+
1440+
updateNavigationSpan(
1441+
validSpan,
1442+
{ pathname: '/captured/path', search: '', hash: '', state: null, key: 'test' },
1443+
[],
1444+
false,
1445+
vi.fn(() => []),
1446+
);
1447+
1448+
// eslint-disable-next-line @typescript-eslint/unbound-method
1449+
expect(validSpan.updateName).toHaveBeenCalled();
1450+
});
1451+
});
14001452
});

0 commit comments

Comments
 (0)