diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts index 3901b0938ca5..b6a97054312a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts @@ -498,3 +498,87 @@ test('Updates navigation transaction name correctly when span is cancelled early expect(['externalFinish', 'cancelled']).toContain(idleSpanFinishReason); } }); + +test('Creates separate transactions for rapid consecutive navigations', async ({ page }) => { + await page.goto('/'); + + // First navigation: / -> /lazy/inner/:id/:anotherId/:someAnotherId + const firstTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + ); + }); + + const navigationToInner = page.locator('id=navigation'); + await expect(navigationToInner).toBeVisible(); + await navigationToInner.click(); + + const firstEvent = await firstTransactionPromise; + + // Verify first transaction + expect(firstEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(firstEvent.contexts?.trace?.op).toBe('navigation'); + const firstTraceId = firstEvent.contexts?.trace?.trace_id; + const firstSpanId = firstEvent.contexts?.trace?.span_id; + + // Second navigation: /lazy/inner -> /another-lazy/sub/:id/:subId + const secondTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/another-lazy/sub/:id/:subId' + ); + }); + + const navigationToAnother = page.locator('id=navigate-to-another-from-inner'); + await expect(navigationToAnother).toBeVisible(); + await navigationToAnother.click(); + + const secondEvent = await secondTransactionPromise; + + // Verify second transaction + expect(secondEvent.transaction).toBe('/another-lazy/sub/:id/:subId'); + expect(secondEvent.contexts?.trace?.op).toBe('navigation'); + const secondTraceId = secondEvent.contexts?.trace?.trace_id; + const secondSpanId = secondEvent.contexts?.trace?.span_id; + + // Third navigation: /another-lazy -> /lazy/inner/:id/:anotherId/:someAnotherId (back to same route as first) + const thirdTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' && + // Ensure we're not matching the first transaction again + transactionEvent.contexts?.trace?.trace_id !== firstTraceId + ); + }); + + const navigationBackToInner = page.locator('id=navigate-to-inner-from-deep'); + await expect(navigationBackToInner).toBeVisible(); + await navigationBackToInner.click(); + + const thirdEvent = await thirdTransactionPromise; + + // Verify third transaction + expect(thirdEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(thirdEvent.contexts?.trace?.op).toBe('navigation'); + const thirdTraceId = thirdEvent.contexts?.trace?.trace_id; + const thirdSpanId = thirdEvent.contexts?.trace?.span_id; + + // Verify each navigation created a separate transaction with unique trace and span IDs + expect(firstTraceId).toBeDefined(); + expect(secondTraceId).toBeDefined(); + expect(thirdTraceId).toBeDefined(); + + // All trace IDs should be unique + expect(firstTraceId).not.toBe(secondTraceId); + expect(secondTraceId).not.toBe(thirdTraceId); + expect(firstTraceId).not.toBe(thirdTraceId); + + // All span IDs should be unique + expect(firstSpanId).not.toBe(secondSpanId); + expect(secondSpanId).not.toBe(thirdSpanId); + expect(firstSpanId).not.toBe(thirdSpanId); +}); diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index a6e55f1a967c..d2e86b006dbf 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -659,8 +659,11 @@ export function handleNavigation(opts: { const spanJson = activeSpan && spanToJSON(activeSpan); const isAlreadyInNavigationSpan = spanJson?.op === 'navigation'; - // Cross usage can result in multiple navigation spans being created without this check - if (!isAlreadyInNavigationSpan) { + // Only skip creating a new span if we're already in a navigation span AND the route name matches. + // This handles cross-usage (multiple wrappers for same navigation) while allowing consecutive navigations. + const isSpanForSameRoute = isAlreadyInNavigationSpan && spanJson?.description === name; + + if (!isSpanForSameRoute) { const navigationSpan = startBrowserTracingNavigationSpan(client, { name, attributes: { diff --git a/packages/react/test/reactrouter-cross-usage.test.tsx b/packages/react/test/reactrouter-cross-usage.test.tsx index 77d8e3d95b2e..a99760597dc0 100644 --- a/packages/react/test/reactrouter-cross-usage.test.tsx +++ b/packages/react/test/reactrouter-cross-usage.test.tsx @@ -9,7 +9,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCurrentClient, } from '@sentry/core'; -import { render } from '@testing-library/react'; +import { act, render, waitFor } from '@testing-library/react'; import * as React from 'react'; import { createMemoryRouter, @@ -607,4 +607,276 @@ describe('React Router cross usage of wrappers', () => { }); }); }); + + describe('consecutive navigations to different routes', () => { + it('should create separate transactions for consecutive navigations to different routes', async () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + + const createSentryMemoryRouter = wrapCreateMemoryRouterV6(createMemoryRouter); + + const router = createSentryMemoryRouter( + [ + { + children: [ + { path: '/users', element: