Skip to content

Commit 4ea5f9d

Browse files
committed
fix(react): Prevent navigation span leaks for consecutive navigations
1 parent ea20d8d commit 4ea5f9d

File tree

3 files changed

+371
-15
lines changed

3 files changed

+371
-15
lines changed

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

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,3 +498,87 @@ test('Updates navigation transaction name correctly when span is cancelled early
498498
expect(['externalFinish', 'cancelled']).toContain(idleSpanFinishReason);
499499
}
500500
});
501+
502+
test('Creates separate transactions for rapid consecutive navigations', async ({ page }) => {
503+
await page.goto('/');
504+
505+
// First navigation: / -> /lazy/inner/:id/:anotherId/:someAnotherId
506+
const firstTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
507+
return (
508+
!!transactionEvent?.transaction &&
509+
transactionEvent.contexts?.trace?.op === 'navigation' &&
510+
transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId'
511+
);
512+
});
513+
514+
const navigationToInner = page.locator('id=navigation');
515+
await expect(navigationToInner).toBeVisible();
516+
await navigationToInner.click();
517+
518+
const firstEvent = await firstTransactionPromise;
519+
520+
// Verify first transaction
521+
expect(firstEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId');
522+
expect(firstEvent.contexts?.trace?.op).toBe('navigation');
523+
const firstTraceId = firstEvent.contexts?.trace?.trace_id;
524+
const firstSpanId = firstEvent.contexts?.trace?.span_id;
525+
526+
// Second navigation: /lazy/inner -> /another-lazy/sub/:id/:subId
527+
const secondTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
528+
return (
529+
!!transactionEvent?.transaction &&
530+
transactionEvent.contexts?.trace?.op === 'navigation' &&
531+
transactionEvent.transaction === '/another-lazy/sub/:id/:subId'
532+
);
533+
});
534+
535+
const navigationToAnother = page.locator('id=navigate-to-another-from-inner');
536+
await expect(navigationToAnother).toBeVisible();
537+
await navigationToAnother.click();
538+
539+
const secondEvent = await secondTransactionPromise;
540+
541+
// Verify second transaction
542+
expect(secondEvent.transaction).toBe('/another-lazy/sub/:id/:subId');
543+
expect(secondEvent.contexts?.trace?.op).toBe('navigation');
544+
const secondTraceId = secondEvent.contexts?.trace?.trace_id;
545+
const secondSpanId = secondEvent.contexts?.trace?.span_id;
546+
547+
// Third navigation: /another-lazy -> /lazy/inner/:id/:anotherId/:someAnotherId (back to same route as first)
548+
const thirdTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
549+
return (
550+
!!transactionEvent?.transaction &&
551+
transactionEvent.contexts?.trace?.op === 'navigation' &&
552+
transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' &&
553+
// Ensure we're not matching the first transaction again
554+
transactionEvent.contexts?.trace?.trace_id !== firstTraceId
555+
);
556+
});
557+
558+
const navigationBackToInner = page.locator('id=navigate-to-inner-from-deep');
559+
await expect(navigationBackToInner).toBeVisible();
560+
await navigationBackToInner.click();
561+
562+
const thirdEvent = await thirdTransactionPromise;
563+
564+
// Verify third transaction
565+
expect(thirdEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId');
566+
expect(thirdEvent.contexts?.trace?.op).toBe('navigation');
567+
const thirdTraceId = thirdEvent.contexts?.trace?.trace_id;
568+
const thirdSpanId = thirdEvent.contexts?.trace?.span_id;
569+
570+
// Verify each navigation created a separate transaction with unique trace and span IDs
571+
expect(firstTraceId).toBeDefined();
572+
expect(secondTraceId).toBeDefined();
573+
expect(thirdTraceId).toBeDefined();
574+
575+
// All trace IDs should be unique
576+
expect(firstTraceId).not.toBe(secondTraceId);
577+
expect(secondTraceId).not.toBe(thirdTraceId);
578+
expect(firstTraceId).not.toBe(thirdTraceId);
579+
580+
// All span IDs should be unique
581+
expect(firstSpanId).not.toBe(secondSpanId);
582+
expect(secondSpanId).not.toBe(thirdSpanId);
583+
expect(firstSpanId).not.toBe(thirdSpanId);
584+
});

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

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,10 @@ export function createV6CompatibleWrapCreateBrowserRouter<
279279
state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete);
280280

281281
if (shouldHandleNavigation) {
282-
const navigationHandler = (): void => {
282+
// Only handle navigation when it's complete (state is idle).
283+
// During 'loading' or 'submitting', state.location may still have the old pathname,
284+
// which would cause us to create a span for the wrong route.
285+
if (state.navigation.state === 'idle') {
283286
handleNavigation({
284287
location: state.location,
285288
routes,
@@ -288,13 +291,6 @@ export function createV6CompatibleWrapCreateBrowserRouter<
288291
basename,
289292
allRoutes: Array.from(allRoutes),
290293
});
291-
};
292-
293-
// Wait for the next render if loading an unsettled route
294-
if (state.navigation.state !== 'idle') {
295-
requestAnimationFrame(navigationHandler);
296-
} else {
297-
navigationHandler();
298294
}
299295
}
300296
});
@@ -632,7 +628,8 @@ export function handleNavigation(opts: {
632628
allRoutes?: RouteObject[];
633629
}): void {
634630
const { location, routes, navigationType, version, matches, basename, allRoutes } = opts;
635-
const branches = Array.isArray(matches) ? matches : _matchRoutes(routes, location, basename);
631+
// Use allRoutes for matching to include lazy-loaded routes
632+
const branches = Array.isArray(matches) ? matches : _matchRoutes(allRoutes || routes, location, basename);
636633

637634
const client = getClient();
638635
if (!client || !CLIENTS_WITH_INSTRUMENT_NAVIGATION.has(client)) {
@@ -649,7 +646,7 @@ export function handleNavigation(opts: {
649646
if ((navigationType === 'PUSH' || navigationType === 'POP') && branches) {
650647
const [name, source] = resolveRouteNameAndSource(
651648
location,
652-
routes,
649+
allRoutes || routes,
653650
allRoutes || routes,
654651
branches as RouteMatch[],
655652
basename,
@@ -659,8 +656,11 @@ export function handleNavigation(opts: {
659656
const spanJson = activeSpan && spanToJSON(activeSpan);
660657
const isAlreadyInNavigationSpan = spanJson?.op === 'navigation';
661658

662-
// Cross usage can result in multiple navigation spans being created without this check
663-
if (!isAlreadyInNavigationSpan) {
659+
// Only skip creating a new span if we're already in a navigation span AND the route name matches.
660+
// This handles cross-usage (multiple wrappers for same navigation) while allowing consecutive navigations.
661+
const isSpanForSameRoute = isAlreadyInNavigationSpan && spanJson?.description === name;
662+
663+
if (!isSpanForSameRoute) {
664664
const navigationSpan = startBrowserTracingNavigationSpan(client, {
665665
name,
666666
attributes: {
@@ -727,7 +727,7 @@ function updatePageloadTransaction({
727727
: (_matchRoutes(allRoutes || routes, location, basename) as unknown as RouteMatch[]);
728728

729729
if (branches) {
730-
const [name, source] = resolveRouteNameAndSource(location, routes, allRoutes || routes, branches, basename);
730+
const [name, source] = resolveRouteNameAndSource(location, allRoutes || routes, allRoutes || routes, branches, basename);
731731

732732
getCurrentScope().setTransactionName(name || '/');
733733

@@ -780,7 +780,7 @@ function patchSpanEnd(
780780
if (branches) {
781781
const [name, source] = resolveRouteNameAndSource(
782782
location,
783-
routes,
783+
currentAllRoutes.length > 0 ? currentAllRoutes : routes,
784784
currentAllRoutes.length > 0 ? currentAllRoutes : routes,
785785
branches,
786786
basename,

0 commit comments

Comments
 (0)