diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx
index 521048fd18f4..7787b60be398 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/react';
import React from 'react';
import ReactDOM from 'react-dom/client';
import {
- Navigate,
PatchRoutesOnNavigationFunction,
RouterProvider,
createBrowserRouter,
@@ -12,6 +11,49 @@ import {
useNavigationType,
} from 'react-router-dom';
import Index from './pages/Index';
+import Deep from './pages/Deep';
+
+function getRuntimeConfig(): { lazyRouteTimeout?: number; idleTimeout?: number } {
+ if (typeof window === 'undefined') {
+ return {};
+ }
+
+ try {
+ const url = new URL(window.location.href);
+ const timeoutParam = url.searchParams.get('timeout');
+ const idleTimeoutParam = url.searchParams.get('idleTimeout');
+
+ let lazyRouteTimeout: number | undefined = undefined;
+ if (timeoutParam) {
+ if (timeoutParam === 'Infinity') {
+ lazyRouteTimeout = Infinity;
+ } else {
+ const parsed = parseInt(timeoutParam, 10);
+ if (!isNaN(parsed)) {
+ lazyRouteTimeout = parsed;
+ }
+ }
+ }
+
+ let idleTimeout: number | undefined = undefined;
+ if (idleTimeoutParam) {
+ const parsed = parseInt(idleTimeoutParam, 10);
+ if (!isNaN(parsed)) {
+ idleTimeout = parsed;
+ }
+ }
+
+ return {
+ lazyRouteTimeout,
+ idleTimeout,
+ };
+ } catch (error) {
+ console.warn('Failed to read runtime config, falling back to defaults', error);
+ return {};
+ }
+}
+
+const runtimeConfig = getRuntimeConfig();
Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
@@ -25,6 +67,8 @@ Sentry.init({
matchRoutes,
trackFetchStreamPerformance: true,
enableAsyncRouteHandlers: true,
+ lazyRouteTimeout: runtimeConfig.lazyRouteTimeout,
+ idleTimeout: runtimeConfig.idleTimeout,
}),
],
// We recommend adjusting this value in production, or using tracesSampler
@@ -66,8 +110,21 @@ const router = sentryCreateBrowserRouter(
element: <>Hello World>,
},
{
- path: '*',
- element: ,
+ path: '/delayed-lazy/:id',
+ lazy: async () => {
+ // Simulate slow lazy route loading (400ms delay)
+ await new Promise(resolve => setTimeout(resolve, 400));
+ return {
+ Component: (await import('./pages/DelayedLazyRoute')).default,
+ };
+ },
+ },
+ {
+ path: '/deep',
+ element: ,
+ handle: {
+ lazyChildren: () => import('./pages/deep/Level1Routes').then(module => module.level2Routes),
+ },
},
],
{
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Deep.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Deep.tsx
new file mode 100644
index 000000000000..c68f7b781e77
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Deep.tsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import { Outlet } from 'react-router-dom';
+
+export default function Deep() {
+ return (
+
+
Deep Route Root
+
You are at the deep route root
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/DelayedLazyRoute.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/DelayedLazyRoute.tsx
new file mode 100644
index 000000000000..41e5ba5463be
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/DelayedLazyRoute.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import { Link, useParams, useLocation, useSearchParams } from 'react-router-dom';
+
+const DelayedLazyRoute = () => {
+ const { id } = useParams<{ id: string }>();
+ const location = useLocation();
+ const [searchParams] = useSearchParams();
+ const view = searchParams.get('view') || 'none';
+ const source = searchParams.get('source') || 'none';
+
+ return (
+
+
Delayed Lazy Route
+
ID: {id}
+
{location.pathname}
+
{location.search}
+
{location.hash}
+
View: {view}
+
Source: {source}
+
+
+
+ Back Home
+
+
+
+ View: Detailed (query param)
+
+
+
+ View: List (query param)
+
+
+
+ Section 1 (hash only)
+
+
+
+ Section 2 (hash only)
+
+
+
+ Grid View + Results (query + hash)
+
+
+
+ );
+};
+
+export default DelayedLazyRoute;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx
index 3053aa57b887..21b965f571f3 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx
@@ -19,6 +19,18 @@ const Index = () => {
Navigate to Long Running Lazy Route
+
+
+ Navigate to Delayed Lazy Parameterized Route
+
+
+
+ Navigate to Delayed Lazy with Query Param
+
+
+
+ Navigate to Deep Nested Route (3 levels, 900ms total)
+
>
);
};
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level1Routes.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level1Routes.tsx
new file mode 100644
index 000000000000..0e0887b8850b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level1Routes.tsx
@@ -0,0 +1,11 @@
+// Delay: 300ms before module loads
+await new Promise(resolve => setTimeout(resolve, 300));
+
+export const level2Routes = [
+ {
+ path: 'level2',
+ handle: {
+ lazyChildren: () => import('./Level2Routes').then(module => module.level3Routes),
+ },
+ },
+];
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level2Routes.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level2Routes.tsx
new file mode 100644
index 000000000000..43671e1b7eee
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level2Routes.tsx
@@ -0,0 +1,14 @@
+// Delay: 300ms before module loads
+await new Promise(resolve => setTimeout(resolve, 300));
+
+export const level3Routes = [
+ {
+ path: 'level3/:id',
+ lazy: async () => {
+ await new Promise(resolve => setTimeout(resolve, 300));
+ return {
+ Component: (await import('./Level3')).default,
+ };
+ },
+ },
+];
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level3.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level3.tsx
new file mode 100644
index 000000000000..e44ecc7da655
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level3.tsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import { useParams } from 'react-router-dom';
+
+export default function Level3() {
+ const { id } = useParams();
+ return (
+
+
Level 3 Deep Route
+
Deeply nested route loaded!
+
ID: {id}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behaviour.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behaviour.test.ts
new file mode 100644
index 000000000000..281ebc88e52c
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behaviour.test.ts
@@ -0,0 +1,126 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+
+test('lazyRouteTimeout: Routes load within timeout window', async ({ page }) => {
+ const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
+ return (
+ !!transactionEvent?.transaction &&
+ transactionEvent.contexts?.trace?.op === 'navigation' &&
+ transactionEvent.transaction.includes('deep')
+ );
+ });
+
+ // Route takes ~900ms, timeout allows 1050ms (50 + 1000)
+ // Routes will load in time → parameterized name
+ await page.goto('/?idleTimeout=50&timeout=1000');
+
+ const navigationLink = page.locator('id=navigation-to-deep');
+ await expect(navigationLink).toBeVisible();
+ await navigationLink.click();
+
+ const event = await transactionPromise;
+
+ // Should get full parameterized route
+ expect(event.transaction).toBe('/deep/level2/level3/:id');
+ expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route');
+ expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout');
+});
+
+test('lazyRouteTimeout: Infinity timeout always waits for routes', async ({ page }) => {
+ const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
+ return (
+ !!transactionEvent?.transaction &&
+ transactionEvent.contexts?.trace?.op === 'navigation' &&
+ transactionEvent.transaction.includes('deep')
+ );
+ });
+
+ // Infinity timeout → waits as long as possible (capped at finalTimeout to prevent indefinite hangs)
+ await page.goto('/?idleTimeout=50&timeout=Infinity');
+
+ const navigationLink = page.locator('id=navigation-to-deep');
+ await expect(navigationLink).toBeVisible();
+ await navigationLink.click();
+
+ const event = await transactionPromise;
+
+ // Should wait for routes to load (up to finalTimeout) and get full route
+ expect(event.transaction).toBe('/deep/level2/level3/:id');
+ expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route');
+ expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout');
+});
+
+test('idleTimeout: Captures all activity with increased timeout', async ({ page }) => {
+ const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
+ return (
+ !!transactionEvent?.transaction &&
+ transactionEvent.contexts?.trace?.op === 'navigation' &&
+ transactionEvent.transaction.includes('deep')
+ );
+ });
+
+ // High idleTimeout (5000ms) ensures transaction captures all lazy loading activity
+ await page.goto('/?idleTimeout=5000');
+
+ const navigationLink = page.locator('id=navigation-to-deep');
+ await expect(navigationLink).toBeVisible();
+ await navigationLink.click();
+
+ const event = await transactionPromise;
+
+ expect(event.transaction).toBe('/deep/level2/level3/:id');
+ expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route');
+ expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout');
+
+ // Transaction should wait for full idle timeout (5+ seconds)
+ const duration = event.timestamp! - event.start_timestamp;
+ expect(duration).toBeGreaterThan(5.0);
+ expect(duration).toBeLessThan(7.0);
+});
+
+test('idleTimeout: Finishes prematurely with low timeout', async ({ page }) => {
+ const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
+ return (
+ !!transactionEvent?.transaction &&
+ transactionEvent.contexts?.trace?.op === 'navigation' &&
+ transactionEvent.transaction.includes('deep')
+ );
+ });
+
+ // Very low idleTimeout (50ms) and lazyRouteTimeout (100ms)
+ // Transaction finishes quickly, but still gets parameterized route name
+ await page.goto('/?idleTimeout=50&timeout=100');
+
+ const navigationLink = page.locator('id=navigation-to-deep');
+ await expect(navigationLink).toBeVisible();
+ await navigationLink.click();
+
+ const event = await transactionPromise;
+
+ expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout');
+ expect(event.transaction).toBe('/deep/level2/level3/:id');
+ expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route');
+
+ // Transaction should finish quickly (< 200ms)
+ const duration = event.timestamp! - event.start_timestamp;
+ expect(duration).toBeLessThan(0.2);
+});
+
+test('idleTimeout: Pageload on deeply nested route', async ({ page }) => {
+ const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
+ return (
+ !!transactionEvent?.transaction &&
+ transactionEvent.contexts?.trace?.op === 'pageload' &&
+ transactionEvent.transaction.includes('deep')
+ );
+ });
+
+ // Direct pageload to deeply nested route (not navigation)
+ await page.goto('/deep/level2/level3/12345');
+
+ const pageloadEvent = await pageloadPromise;
+
+ expect(pageloadEvent.transaction).toBe('/deep/level2/level3/:id');
+ expect(pageloadEvent.contexts?.trace?.data?.['sentry.source']).toBe('route');
+ expect(pageloadEvent.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout');
+});
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 e5b9f35042ed..ce8137d7f686 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
@@ -588,3 +588,298 @@ test('Creates separate transactions for rapid consecutive navigations', async ({
expect(secondSpanId).not.toBe(thirdSpanId);
expect(firstSpanId).not.toBe(thirdSpanId);
});
+
+test('Creates pageload transaction with parameterized route for delayed lazy route', async ({ page }) => {
+ const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
+ return (
+ !!transactionEvent?.transaction &&
+ transactionEvent.contexts?.trace?.op === 'pageload' &&
+ transactionEvent.transaction === '/delayed-lazy/:id'
+ );
+ });
+
+ await page.goto('/delayed-lazy/123');
+
+ const pageloadEvent = await pageloadPromise;
+
+ const delayedReady = page.locator('id=delayed-lazy-ready');
+ await expect(delayedReady).toBeVisible();
+ await expect(page.locator('id=delayed-lazy-id')).toHaveText('ID: 123');
+ await expect(page.locator('id=delayed-lazy-path')).toHaveText('/delayed-lazy/123');
+
+ expect(pageloadEvent.transaction).toBe('/delayed-lazy/:id');
+ expect(pageloadEvent.contexts?.trace?.op).toBe('pageload');
+ expect(pageloadEvent.contexts?.trace?.data?.['sentry.source']).toBe('route');
+});
+
+test('Creates navigation transaction with parameterized route for delayed lazy route', async ({ page }) => {
+ await page.goto('/');
+
+ const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
+ return (
+ !!transactionEvent?.transaction &&
+ transactionEvent.contexts?.trace?.op === 'navigation' &&
+ transactionEvent.transaction === '/delayed-lazy/:id'
+ );
+ });
+
+ const navigationLink = page.locator('id=navigation-to-delayed-lazy');
+ await expect(navigationLink).toBeVisible();
+ await navigationLink.click();
+
+ const navigationEvent = await navigationPromise;
+
+ const delayedReady = page.locator('id=delayed-lazy-ready');
+ await expect(delayedReady).toBeVisible();
+ await expect(page.locator('id=delayed-lazy-id')).toHaveText('ID: 123');
+ await expect(page.locator('id=delayed-lazy-path')).toHaveText('/delayed-lazy/123');
+
+ expect(navigationEvent.transaction).toBe('/delayed-lazy/:id');
+ expect(navigationEvent.contexts?.trace?.op).toBe('navigation');
+ expect(navigationEvent.contexts?.trace?.data?.['sentry.source']).toBe('route');
+});
+
+test('Creates navigation transaction when navigating with query parameters from home to route', async ({ page }) => {
+ await page.goto('/');
+
+ // Navigate from / to /delayed-lazy/123?source=homepage
+ // This should create a navigation transaction with the parameterized route name
+ const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
+ return (
+ !!transactionEvent?.transaction &&
+ transactionEvent.contexts?.trace?.op === 'navigation' &&
+ transactionEvent.transaction === '/delayed-lazy/:id'
+ );
+ });
+
+ const navigationLink = page.locator('id=navigation-to-delayed-lazy-with-query');
+ await expect(navigationLink).toBeVisible();
+ await navigationLink.click();
+
+ const navigationEvent = await navigationPromise;
+
+ const delayedReady = page.locator('id=delayed-lazy-ready');
+ await expect(delayedReady).toBeVisible();
+ await expect(page.locator('id=delayed-lazy-id')).toHaveText('ID: 123');
+ await expect(page.locator('id=delayed-lazy-path')).toHaveText('/delayed-lazy/123');
+ await expect(page.locator('id=delayed-lazy-search')).toHaveText('?source=homepage');
+ await expect(page.locator('id=delayed-lazy-source')).toHaveText('Source: homepage');
+
+ // Verify the navigation transaction has the correct parameterized route name
+ // Query parameters should NOT affect the transaction name (still /delayed-lazy/:id)
+ expect(navigationEvent.transaction).toBe('/delayed-lazy/:id');
+ expect(navigationEvent.contexts?.trace?.op).toBe('navigation');
+ expect(navigationEvent.contexts?.trace?.data?.['sentry.source']).toBe('route');
+ expect(navigationEvent.contexts?.trace?.status).toBe('ok');
+});
+
+test('Creates separate navigation transaction when changing only query parameters on same route', async ({ page }) => {
+ await page.goto('/delayed-lazy/123');
+
+ // Wait for the page to fully load
+ const delayedReady = page.locator('id=delayed-lazy-ready');
+ await expect(delayedReady).toBeVisible();
+
+ // Navigate from /delayed-lazy/123 to /delayed-lazy/123?view=detailed
+ // This is a query-only change on the same route
+ const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
+ return (
+ !!transactionEvent?.transaction &&
+ transactionEvent.contexts?.trace?.op === 'navigation' &&
+ transactionEvent.transaction === '/delayed-lazy/:id'
+ );
+ });
+
+ const queryLink = page.locator('id=link-to-query-view-detailed');
+ await expect(queryLink).toBeVisible();
+ await queryLink.click();
+
+ const navigationEvent = await navigationPromise;
+
+ // Verify query param was updated
+ await expect(page.locator('id=delayed-lazy-search')).toHaveText('?view=detailed');
+ await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: detailed');
+
+ // Query-only navigation should create a navigation transaction
+ expect(navigationEvent.transaction).toBe('/delayed-lazy/:id');
+ expect(navigationEvent.contexts?.trace?.op).toBe('navigation');
+ expect(navigationEvent.contexts?.trace?.data?.['sentry.source']).toBe('route');
+ expect(navigationEvent.contexts?.trace?.status).toBe('ok');
+});
+
+test('Creates separate navigation transactions for multiple query parameter changes', async ({ page }) => {
+ await page.goto('/delayed-lazy/123');
+
+ const delayedReady = page.locator('id=delayed-lazy-ready');
+ await expect(delayedReady).toBeVisible();
+
+ // First query change: /delayed-lazy/123 -> /delayed-lazy/123?view=detailed
+ const firstNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
+ return (
+ !!transactionEvent?.transaction &&
+ transactionEvent.contexts?.trace?.op === 'navigation' &&
+ transactionEvent.transaction === '/delayed-lazy/:id'
+ );
+ });
+
+ const firstQueryLink = page.locator('id=link-to-query-view-detailed');
+ await expect(firstQueryLink).toBeVisible();
+ await firstQueryLink.click();
+
+ const firstNavigationEvent = await firstNavigationPromise;
+ const firstTraceId = firstNavigationEvent.contexts?.trace?.trace_id;
+
+ await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: detailed');
+
+ // Second query change: /delayed-lazy/123?view=detailed -> /delayed-lazy/123?view=list
+ const secondNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
+ return (
+ !!transactionEvent?.transaction &&
+ transactionEvent.contexts?.trace?.op === 'navigation' &&
+ transactionEvent.transaction === '/delayed-lazy/:id' &&
+ transactionEvent.contexts?.trace?.trace_id !== firstTraceId
+ );
+ });
+
+ const secondQueryLink = page.locator('id=link-to-query-view-list');
+ await expect(secondQueryLink).toBeVisible();
+ await secondQueryLink.click();
+
+ const secondNavigationEvent = await secondNavigationPromise;
+ const secondTraceId = secondNavigationEvent.contexts?.trace?.trace_id;
+
+ await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: list');
+
+ // Both navigations should have created separate transactions
+ expect(firstNavigationEvent.transaction).toBe('/delayed-lazy/:id');
+ expect(firstNavigationEvent.contexts?.trace?.op).toBe('navigation');
+ expect(secondNavigationEvent.transaction).toBe('/delayed-lazy/:id');
+ expect(secondNavigationEvent.contexts?.trace?.op).toBe('navigation');
+
+ // Trace IDs should be different (separate transactions)
+ expect(firstTraceId).toBeDefined();
+ expect(secondTraceId).toBeDefined();
+ expect(firstTraceId).not.toBe(secondTraceId);
+});
+
+test('Creates navigation transaction when changing only hash on same route', async ({ page }) => {
+ await page.goto('/delayed-lazy/123');
+
+ const delayedReady = page.locator('id=delayed-lazy-ready');
+ await expect(delayedReady).toBeVisible();
+
+ // Navigate from /delayed-lazy/123 to /delayed-lazy/123#section1
+ // This is a hash-only change on the same route
+ const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
+ return (
+ !!transactionEvent?.transaction &&
+ transactionEvent.contexts?.trace?.op === 'navigation' &&
+ transactionEvent.transaction === '/delayed-lazy/:id'
+ );
+ });
+
+ const hashLink = page.locator('id=link-to-hash-section1');
+ await expect(hashLink).toBeVisible();
+ await hashLink.click();
+
+ const navigationEvent = await navigationPromise;
+
+ // Verify hash was updated
+ await expect(page.locator('id=delayed-lazy-hash')).toHaveText('#section1');
+
+ // Hash-only navigation should create a navigation transaction
+ expect(navigationEvent.transaction).toBe('/delayed-lazy/:id');
+ expect(navigationEvent.contexts?.trace?.op).toBe('navigation');
+ expect(navigationEvent.contexts?.trace?.data?.['sentry.source']).toBe('route');
+ expect(navigationEvent.contexts?.trace?.status).toBe('ok');
+});
+
+test('Creates separate navigation transactions for multiple hash changes', async ({ page }) => {
+ await page.goto('/delayed-lazy/123');
+
+ const delayedReady = page.locator('id=delayed-lazy-ready');
+ await expect(delayedReady).toBeVisible();
+
+ // First hash change: /delayed-lazy/123 -> /delayed-lazy/123#section1
+ const firstNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
+ return (
+ !!transactionEvent?.transaction &&
+ transactionEvent.contexts?.trace?.op === 'navigation' &&
+ transactionEvent.transaction === '/delayed-lazy/:id'
+ );
+ });
+
+ const firstHashLink = page.locator('id=link-to-hash-section1');
+ await expect(firstHashLink).toBeVisible();
+ await firstHashLink.click();
+
+ const firstNavigationEvent = await firstNavigationPromise;
+ const firstTraceId = firstNavigationEvent.contexts?.trace?.trace_id;
+
+ await expect(page.locator('id=delayed-lazy-hash')).toHaveText('#section1');
+
+ // Second hash change: /delayed-lazy/123#section1 -> /delayed-lazy/123#section2
+ const secondNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
+ return (
+ !!transactionEvent?.transaction &&
+ transactionEvent.contexts?.trace?.op === 'navigation' &&
+ transactionEvent.transaction === '/delayed-lazy/:id' &&
+ transactionEvent.contexts?.trace?.trace_id !== firstTraceId
+ );
+ });
+
+ const secondHashLink = page.locator('id=link-to-hash-section2');
+ await expect(secondHashLink).toBeVisible();
+ await secondHashLink.click();
+
+ const secondNavigationEvent = await secondNavigationPromise;
+ const secondTraceId = secondNavigationEvent.contexts?.trace?.trace_id;
+
+ await expect(page.locator('id=delayed-lazy-hash')).toHaveText('#section2');
+
+ // Both navigations should have created separate transactions
+ expect(firstNavigationEvent.transaction).toBe('/delayed-lazy/:id');
+ expect(firstNavigationEvent.contexts?.trace?.op).toBe('navigation');
+ expect(secondNavigationEvent.transaction).toBe('/delayed-lazy/:id');
+ expect(secondNavigationEvent.contexts?.trace?.op).toBe('navigation');
+
+ // Trace IDs should be different (separate transactions)
+ expect(firstTraceId).toBeDefined();
+ expect(secondTraceId).toBeDefined();
+ expect(firstTraceId).not.toBe(secondTraceId);
+});
+
+test('Creates navigation transaction when changing both query and hash on same route', async ({ page }) => {
+ await page.goto('/delayed-lazy/123?view=list');
+
+ const delayedReady = page.locator('id=delayed-lazy-ready');
+ await expect(delayedReady).toBeVisible();
+ await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: list');
+
+ // Navigate from /delayed-lazy/123?view=list to /delayed-lazy/123?view=grid#results
+ // This changes both query and hash
+ const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
+ return (
+ !!transactionEvent?.transaction &&
+ transactionEvent.contexts?.trace?.op === 'navigation' &&
+ transactionEvent.transaction === '/delayed-lazy/:id'
+ );
+ });
+
+ const queryAndHashLink = page.locator('id=link-to-query-and-hash');
+ await expect(queryAndHashLink).toBeVisible();
+ await queryAndHashLink.click();
+
+ const navigationEvent = await navigationPromise;
+
+ // Verify both query and hash were updated
+ await expect(page.locator('id=delayed-lazy-search')).toHaveText('?view=grid');
+ await expect(page.locator('id=delayed-lazy-hash')).toHaveText('#results');
+ await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: grid');
+
+ // Combined query + hash navigation should create a navigation transaction
+ expect(navigationEvent.transaction).toBe('/delayed-lazy/:id');
+ expect(navigationEvent.contexts?.trace?.op).toBe('navigation');
+ expect(navigationEvent.contexts?.trace?.data?.['sentry.source']).toBe('route');
+ expect(navigationEvent.contexts?.trace?.status).toBe('ok');
+});
diff --git a/packages/react/src/reactrouter-compat-utils/index.ts b/packages/react/src/reactrouter-compat-utils/index.ts
index c2b56ec446fb..bb91ba8d3072 100644
--- a/packages/react/src/reactrouter-compat-utils/index.ts
+++ b/packages/react/src/reactrouter-compat-utils/index.ts
@@ -25,6 +25,7 @@ export {
pathEndsWithWildcard,
pathIsWildcardAndHasChildren,
getNumberOfUrlSegments,
+ transactionNameHasWildcard,
} from './utils';
// Lazy route exports
diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx
index 235b207ed9a0..6e19b9021ba5 100644
--- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx
+++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx
@@ -41,59 +41,118 @@ import type {
UseRoutes,
} from '../types';
import { checkRouteForAsyncHandler } from './lazy-routes';
-import { initializeRouterUtils, resolveRouteNameAndSource } from './utils';
+import { initializeRouterUtils, resolveRouteNameAndSource, transactionNameHasWildcard } from './utils';
let _useEffect: UseEffect;
let _useLocation: UseLocation;
let _useNavigationType: UseNavigationType;
let _createRoutesFromChildren: CreateRoutesFromChildren;
let _matchRoutes: MatchRoutes;
+
let _enableAsyncRouteHandlers: boolean = false;
+let _lazyRouteTimeout = 3000;
const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet();
+// Prevents duplicate spans when router.subscribe fires multiple times
+const activeNavigationSpans = new WeakMap<
+ Client,
+ { span: Span; routeName: string; pathname: string; locationKey: string; isPlaceholder?: boolean }
+>();
+
+// Exported for testing only
+export const allRoutes = new Set();
+
+// Tracks lazy route loads to wait before finalizing span names
+const pendingLazyRouteLoads = new WeakMap>>();
+
/**
- * Tracks last navigation per client to prevent duplicate spans in cross-usage scenarios.
- * Entry persists until the navigation span ends, allowing cross-usage detection during delayed wrapper execution.
+ * Schedules a callback using requestAnimationFrame when available (browser),
+ * or falls back to setTimeout for SSR environments (Node.js, createMemoryRouter tests).
*/
-const LAST_NAVIGATION_PER_CLIENT = new WeakMap();
+function scheduleCallback(callback: () => void): number {
+ if (WINDOW?.requestAnimationFrame) {
+ return WINDOW.requestAnimationFrame(callback);
+ }
+ return setTimeout(callback, 0) as unknown as number;
+}
-export function addResolvedRoutesToParent(resolvedRoutes: RouteObject[], parentRoute: RouteObject): void {
- const existingChildren = parentRoute.children || [];
+/**
+ * Cancels a scheduled callback, handling both RAF (browser) and timeout (SSR) IDs.
+ */
+function cancelScheduledCallback(id: number): void {
+ if (WINDOW?.cancelAnimationFrame) {
+ WINDOW.cancelAnimationFrame(id);
+ } else {
+ clearTimeout(id);
+ }
+}
- const newRoutes = resolvedRoutes.filter(
- newRoute =>
- !existingChildren.some(
- existing =>
- existing === newRoute ||
- (newRoute.path && existing.path === newRoute.path) ||
- (newRoute.id && existing.id === newRoute.id),
- ),
- );
+/**
+ * Computes location key for duplicate detection. Normalizes undefined/null to empty strings.
+ * Exported for testing.
+ */
+export function computeLocationKey(location: Location): string {
+ return `${location.pathname}${location.search || ''}${location.hash || ''}`;
+}
- if (newRoutes.length > 0) {
- parentRoute.children = [...existingChildren, ...newRoutes];
- }
+/**
+ * Checks if a route name is parameterized (contains route parameters like :id or wildcards like *)
+ * vs a raw URL path.
+ */
+function isParameterizedRoute(routeName: string): boolean {
+ return routeName.includes(':') || routeName.includes('*');
}
/**
- * Determines if a navigation should be handled based on router state.
- * Only handles:
- * - PUSH navigations (always)
- * - POP navigations (only after initial pageload is complete)
- * - When router state is 'idle' (not 'loading' or 'submitting')
+ * Determines if a navigation should be skipped as a duplicate, and if an existing span should be updated.
+ * Exported for testing.
*
- * During 'loading' or 'submitting', state.location may still have the old pathname,
- * which would cause us to create a span for the wrong route.
+ * @returns An object with:
+ * - skip: boolean - Whether to skip creating a new span
+ * - shouldUpdate: boolean - Whether to update the existing span name (wildcard upgrade)
*/
-function shouldHandleNavigation(
- state: { historyAction: string; navigation: { state: string } },
- isInitialPageloadComplete: boolean,
-): boolean {
- return (
- (state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete)) &&
- state.navigation.state === 'idle'
- );
+export function shouldSkipNavigation(
+ trackedNav:
+ | { span: Span; routeName: string; pathname: string; locationKey: string; isPlaceholder?: boolean }
+ | undefined,
+ locationKey: string,
+ proposedName: string,
+ spanHasEnded: boolean,
+): { skip: boolean; shouldUpdate: boolean } {
+ if (!trackedNav) {
+ return { skip: false, shouldUpdate: false };
+ }
+
+ // Check if this is a duplicate navigation (same location)
+ // 1. If it's a placeholder, it's always a duplicate (we're waiting for the real one)
+ // 2. If it's a real span, it's a duplicate only if it hasn't ended yet
+ const isDuplicate = trackedNav.locationKey === locationKey && (trackedNav.isPlaceholder || !spanHasEnded);
+
+ if (isDuplicate) {
+ // Check if we should update the span name with a better route
+ // Allow updates if:
+ // 1. Current has wildcard and new doesn't (wildcard → parameterized upgrade)
+ // 2. Current is raw path and new is parameterized (raw → parameterized upgrade)
+ // 3. New name is different and more specific (longer, indicating nested routes resolved)
+ const currentHasWildcard = !!trackedNav.routeName && transactionNameHasWildcard(trackedNav.routeName);
+ const proposedHasWildcard = transactionNameHasWildcard(proposedName);
+ const currentIsParameterized = !!trackedNav.routeName && isParameterizedRoute(trackedNav.routeName);
+ const proposedIsParameterized = isParameterizedRoute(proposedName);
+
+ const isWildcardUpgrade = currentHasWildcard && !proposedHasWildcard;
+ const isRawToParameterized = !currentIsParameterized && proposedIsParameterized;
+ const isMoreSpecific =
+ proposedName !== trackedNav.routeName &&
+ proposedName.length > (trackedNav.routeName?.length || 0) &&
+ !proposedHasWildcard;
+
+ const shouldUpdate = !!(trackedNav.routeName && (isWildcardUpgrade || isRawToParameterized || isMoreSpecific));
+
+ return { skip: true, shouldUpdate };
+ }
+
+ return { skip: false, shouldUpdate: false };
}
export interface ReactRouterOptions {
@@ -116,13 +175,58 @@ export interface ReactRouterOptions {
* @default false
*/
enableAsyncRouteHandlers?: boolean;
+
+ /**
+ * Maximum time (in milliseconds) to wait for lazy routes to load before finalizing span names.
+ *
+ * - Set to `0` to not wait at all (immediate finalization)
+ * - Set to `Infinity` to wait as long as possible (capped at `finalTimeout` to prevent indefinite hangs)
+ * - Negative values will fall back to the default
+ *
+ * Defaults to 3× the configured `idleTimeout` (default: 3000ms).
+ *
+ * @default idleTimeout * 3
+ */
+ lazyRouteTimeout?: number;
}
type V6CompatibleVersion = '6' | '7';
-// Keeping as a global variable for cross-usage in multiple functions
-// only exported for testing purposes
-export const allRoutes = new Set();
+export function addResolvedRoutesToParent(resolvedRoutes: RouteObject[], parentRoute: RouteObject): void {
+ const existingChildren = parentRoute.children || [];
+
+ const newRoutes = resolvedRoutes.filter(
+ newRoute =>
+ !existingChildren.some(
+ existing =>
+ existing === newRoute ||
+ (newRoute.path && existing.path === newRoute.path) ||
+ (newRoute.id && existing.id === newRoute.id),
+ ),
+ );
+
+ if (newRoutes.length > 0) {
+ parentRoute.children = [...existingChildren, ...newRoutes];
+ }
+}
+
+/** Registers a pending lazy route load promise for a span. */
+function trackLazyRouteLoad(span: Span, promise: Promise): void {
+ let promises = pendingLazyRouteLoads.get(span);
+ if (!promises) {
+ promises = new Set();
+ pendingLazyRouteLoads.set(span, promises);
+ }
+ promises.add(promise);
+
+ // Clean up when promise resolves/rejects
+ promise.finally(() => {
+ const currentPromises = pendingLazyRouteLoads.get(span);
+ if (currentPromises) {
+ currentPromises.delete(promise);
+ }
+ });
+}
/**
* Processes resolved routes by adding them to allRoutes and checking for nested async handlers.
@@ -188,13 +292,14 @@ export function updateNavigationSpan(
forceUpdate = false,
matchRoutes: MatchRoutes,
): void {
- // Check if this span has already been named to avoid multiple updates
- // But allow updates if this is a forced update (e.g., when lazy routes are loaded)
- const hasBeenNamed =
- !forceUpdate && (activeRootSpan as { __sentry_navigation_name_set__?: boolean })?.__sentry_navigation_name_set__;
+ const spanJson = spanToJSON(activeRootSpan);
+ const currentName = spanJson.description;
- if (!hasBeenNamed) {
- // Get fresh branches for the current location with all loaded routes
+ const hasBeenNamed = (activeRootSpan as { __sentry_navigation_name_set__?: boolean })?.__sentry_navigation_name_set__;
+ const currentNameHasWildcard = currentName && transactionNameHasWildcard(currentName);
+ const shouldUpdate = !hasBeenNamed || forceUpdate || currentNameHasWildcard;
+
+ if (shouldUpdate && !spanJson.timestamp) {
const currentBranches = matchRoutes(allRoutes, location);
const [name, source] = resolveRouteNameAndSource(
location,
@@ -204,22 +309,105 @@ export function updateNavigationSpan(
'',
);
- // Only update if we have a valid name and the span hasn't finished
- const spanJson = spanToJSON(activeRootSpan);
- if (name && !spanJson.timestamp) {
+ const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
+ const isImprovement =
+ name &&
+ (!currentName || // No current name - always set
+ (!hasBeenNamed && (currentSource !== 'route' || source === 'route')) || // Not finalized - allow unless downgrading route→url
+ (currentSource !== 'route' && source === 'route') || // URL → route upgrade
+ (currentSource === 'route' && source === 'route' && currentNameHasWildcard)); // Route → better route (only if current has wildcard)
+ if (isImprovement) {
activeRootSpan.updateName(name);
activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
- // Mark this span as having its name set to prevent future updates
- addNonEnumerableProperty(
- activeRootSpan as { __sentry_navigation_name_set__?: boolean },
- '__sentry_navigation_name_set__',
- true,
- );
+ // Only mark as finalized for non-wildcard route names (allows URL→route upgrades).
+ if (!transactionNameHasWildcard(name) && source === 'route') {
+ addNonEnumerableProperty(
+ activeRootSpan as { __sentry_navigation_name_set__?: boolean },
+ '__sentry_navigation_name_set__',
+ true,
+ );
+ }
}
}
}
+function setupRouterSubscription(
+ router: Router,
+ routes: RouteObject[],
+ version: V6CompatibleVersion,
+ basename: string | undefined,
+ activeRootSpan: Span | undefined,
+): void {
+ let isInitialPageloadComplete = false;
+ let hasSeenPageloadSpan = !!activeRootSpan && spanToJSON(activeRootSpan).op === 'pageload';
+ let hasSeenPopAfterPageload = false;
+ let scheduledNavigationHandler: number | null = null;
+ let lastHandledPathname: string | null = null;
+
+ router.subscribe((state: RouterState) => {
+ if (!isInitialPageloadComplete) {
+ const currentRootSpan = getActiveRootSpan();
+ const isCurrentlyInPageload = currentRootSpan && spanToJSON(currentRootSpan).op === 'pageload';
+
+ if (isCurrentlyInPageload) {
+ hasSeenPageloadSpan = true;
+ } else if (hasSeenPageloadSpan) {
+ if (state.historyAction === 'POP' && !hasSeenPopAfterPageload) {
+ hasSeenPopAfterPageload = true;
+ } else {
+ isInitialPageloadComplete = true;
+ }
+ }
+ }
+
+ const shouldHandleNavigation =
+ state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete);
+
+ if (shouldHandleNavigation) {
+ // Include search and hash to allow query/hash-only navigations
+ // Use computeLocationKey() to ensure undefined/null values are normalized to empty strings
+ const currentLocationKey = computeLocationKey(state.location);
+ const navigationHandler = (): void => {
+ // Prevent multiple calls for the same location within the same navigation cycle
+ if (lastHandledPathname === currentLocationKey) {
+ return;
+ }
+ lastHandledPathname = currentLocationKey;
+ scheduledNavigationHandler = null;
+ handleNavigation({
+ location: state.location,
+ routes,
+ navigationType: state.historyAction,
+ version,
+ basename,
+ allRoutes: Array.from(allRoutes),
+ });
+ };
+
+ if (state.navigation.state !== 'idle') {
+ // Navigation in progress - reset if location changed
+ if (lastHandledPathname !== currentLocationKey) {
+ lastHandledPathname = null;
+ }
+ // Cancel any previously scheduled handler to avoid duplicates
+ if (scheduledNavigationHandler !== null) {
+ cancelScheduledCallback(scheduledNavigationHandler);
+ }
+ scheduledNavigationHandler = scheduleCallback(navigationHandler);
+ } else {
+ // Navigation completed - cancel scheduled handler if any, then call immediately
+ if (scheduledNavigationHandler !== null) {
+ cancelScheduledCallback(scheduledNavigationHandler);
+ scheduledNavigationHandler = null;
+ }
+ navigationHandler();
+ // Don't reset - next navigation cycle resets to prevent duplicates within same cycle.
+ }
+ }
+ });
+}
+
/**
* Creates a wrapCreateBrowserRouter function that can be used with all React Router v6 compatible versions.
*/
@@ -242,30 +430,17 @@ export function createV6CompatibleWrapCreateBrowserRouter<
return function (routes: RouteObject[], opts?: Record & { basename?: string }): TRouter {
addRoutesToAllRoutes(routes);
- // Check for async handlers that might contain sub-route declarations (only if enabled)
if (_enableAsyncRouteHandlers) {
for (const route of routes) {
checkRouteForAsyncHandler(route, processResolvedRoutes);
}
}
- // Wrap patchRoutesOnNavigation to detect when lazy routes are loaded
const wrappedOpts = wrapPatchRoutesOnNavigation(opts);
-
const router = createRouterFunction(routes, wrappedOpts);
const basename = opts?.basename;
-
const activeRootSpan = getActiveRootSpan();
- // Track whether we've completed the initial pageload to properly distinguish
- // between POPs that occur during pageload vs. legitimate back/forward navigation.
- let isInitialPageloadComplete = false;
- let hasSeenPageloadSpan = !!activeRootSpan && spanToJSON(activeRootSpan).op === 'pageload';
- let hasSeenPopAfterPageload = false;
-
- // The initial load ends when `createBrowserRouter` is called.
- // This is the earliest convenient time to update the transaction name.
- // Callbacks to `router.subscribe` are not called for the initial load.
if (router.state.historyAction === 'POP' && activeRootSpan) {
updatePageloadTransaction({
activeRootSpan,
@@ -276,38 +451,7 @@ export function createV6CompatibleWrapCreateBrowserRouter<
});
}
- router.subscribe((state: RouterState) => {
- // Track pageload completion to distinguish POPs during pageload from legitimate back/forward navigation
- if (!isInitialPageloadComplete) {
- const currentRootSpan = getActiveRootSpan();
- const isCurrentlyInPageload = currentRootSpan && spanToJSON(currentRootSpan).op === 'pageload';
-
- if (isCurrentlyInPageload) {
- hasSeenPageloadSpan = true;
- } else if (hasSeenPageloadSpan) {
- // Pageload span was active but is now gone - pageload has completed
- if (state.historyAction === 'POP' && !hasSeenPopAfterPageload) {
- // Pageload ended: ignore the first POP after pageload
- hasSeenPopAfterPageload = true;
- } else {
- // Pageload ended: either non-POP action or subsequent POP
- isInitialPageloadComplete = true;
- }
- }
- // If we haven't seen a pageload span yet, keep waiting (don't mark as complete)
- }
-
- if (shouldHandleNavigation(state, isInitialPageloadComplete)) {
- handleNavigation({
- location: state.location,
- routes,
- navigationType: state.historyAction,
- version,
- basename,
- allRoutes: Array.from(allRoutes),
- });
- }
- });
+ setupRouterSubscription(router, routes, version, basename, activeRootSpan);
return router;
};
@@ -342,14 +486,12 @@ export function createV6CompatibleWrapCreateMemoryRouter<
): TRouter {
addRoutesToAllRoutes(routes);
- // Check for async handlers that might contain sub-route declarations (only if enabled)
if (_enableAsyncRouteHandlers) {
for (const route of routes) {
checkRouteForAsyncHandler(route, processResolvedRoutes);
}
}
- // Wrap patchRoutesOnNavigation to detect when lazy routes are loaded
const wrappedOpts = wrapPatchRoutesOnNavigation(opts, true);
const router = createRouterFunction(routes, wrappedOpts);
@@ -387,44 +529,7 @@ export function createV6CompatibleWrapCreateMemoryRouter<
});
}
- // Track whether we've completed the initial pageload to properly distinguish
- // between POPs that occur during pageload vs. legitimate back/forward navigation.
- let isInitialPageloadComplete = false;
- let hasSeenPageloadSpan = !!memoryActiveRootSpan && spanToJSON(memoryActiveRootSpan).op === 'pageload';
- let hasSeenPopAfterPageload = false;
-
- router.subscribe((state: RouterState) => {
- // Track pageload completion to distinguish POPs during pageload from legitimate back/forward navigation
- if (!isInitialPageloadComplete) {
- const currentRootSpan = getActiveRootSpan();
- const isCurrentlyInPageload = currentRootSpan && spanToJSON(currentRootSpan).op === 'pageload';
-
- if (isCurrentlyInPageload) {
- hasSeenPageloadSpan = true;
- } else if (hasSeenPageloadSpan) {
- // Pageload span was active but is now gone - pageload has completed
- if (state.historyAction === 'POP' && !hasSeenPopAfterPageload) {
- // Pageload ended: ignore the first POP after pageload
- hasSeenPopAfterPageload = true;
- } else {
- // Pageload ended: either non-POP action or subsequent POP
- isInitialPageloadComplete = true;
- }
- }
- // If we haven't seen a pageload span yet, keep waiting (don't mark as complete)
- }
-
- if (shouldHandleNavigation(state, isInitialPageloadComplete)) {
- handleNavigation({
- location: state.location,
- routes,
- navigationType: state.historyAction,
- version,
- basename,
- allRoutes: Array.from(allRoutes),
- });
- }
- });
+ setupRouterSubscription(router, routes, version, basename, memoryActiveRootSpan);
return router;
};
@@ -449,6 +554,7 @@ export function createReactRouterV6CompatibleTracingIntegration(
enableAsyncRouteHandlers = false,
instrumentPageLoad = true,
instrumentNavigation = true,
+ lazyRouteTimeout,
} = options;
return {
@@ -456,6 +562,36 @@ export function createReactRouterV6CompatibleTracingIntegration(
setup(client) {
integration.setup(client);
+ const finalTimeout = options.finalTimeout ?? 30000;
+ const defaultMaxWait = (options.idleTimeout ?? 1000) * 3;
+ const configuredMaxWait = lazyRouteTimeout ?? defaultMaxWait;
+
+ // Cap Infinity at finalTimeout to prevent indefinite hangs
+ if (configuredMaxWait === Infinity) {
+ _lazyRouteTimeout = finalTimeout;
+ DEBUG_BUILD &&
+ debug.log(
+ '[React Router] lazyRouteTimeout set to Infinity, capping at finalTimeout:',
+ finalTimeout,
+ 'ms to prevent indefinite hangs',
+ );
+ } else if (Number.isNaN(configuredMaxWait)) {
+ DEBUG_BUILD &&
+ debug.warn('[React Router] lazyRouteTimeout must be a number, falling back to default:', defaultMaxWait);
+ _lazyRouteTimeout = defaultMaxWait;
+ } else if (configuredMaxWait < 0) {
+ DEBUG_BUILD &&
+ debug.warn(
+ '[React Router] lazyRouteTimeout must be non-negative or Infinity, got:',
+ configuredMaxWait,
+ 'falling back to:',
+ defaultMaxWait,
+ );
+ _lazyRouteTimeout = defaultMaxWait;
+ } else {
+ _lazyRouteTimeout = configuredMaxWait;
+ }
+
_useEffect = useEffect;
_useLocation = useLocation;
_useNavigationType = useNavigationType;
@@ -530,6 +666,9 @@ export function createV6CompatibleWrapUseRoutes(origUseRoutes: UseRoutes, versio
});
isMountRenderPass.current = false;
} else {
+ // Note: Component-based routes don't support lazy route tracking via lazyRouteTimeout
+ // because React.lazy() loads happen at the component level, not the router level.
+ // Use createBrowserRouter with patchRoutesOnNavigation for lazy route tracking.
handleNavigation({
location: normalizedLocation,
routes,
@@ -564,7 +703,8 @@ function wrapPatchRoutesOnNavigation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
const targetPath = (args as any)?.path;
- // For browser router, wrap the patch function to update span during patching
+ const activeRootSpan = getActiveRootSpan();
+
if (!isMemoryRouter) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
const originalPatch = (args as any)?.patch;
@@ -572,13 +712,13 @@ function wrapPatchRoutesOnNavigation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
(args as any).patch = (routeId: string, children: RouteObject[]) => {
addRoutesToAllRoutes(children);
- const activeRootSpan = getActiveRootSpan();
- if (activeRootSpan && (spanToJSON(activeRootSpan) as { op?: string }).op === 'navigation') {
+ const currentActiveRootSpan = getActiveRootSpan();
+ if (currentActiveRootSpan && (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation') {
updateNavigationSpan(
- activeRootSpan,
+ currentActiveRootSpan,
{ pathname: targetPath, search: '', hash: '', state: null, key: 'default' },
Array.from(allRoutes),
- true, // forceUpdate = true since we're loading lazy routes
+ true,
_matchRoutes,
);
}
@@ -587,102 +727,37 @@ function wrapPatchRoutesOnNavigation(
}
}
- const result = await originalPatchRoutes(args);
-
- // Update navigation span after routes are patched
- const activeRootSpan = getActiveRootSpan();
- if (activeRootSpan && (spanToJSON(activeRootSpan) as { op?: string }).op === 'navigation') {
- // Determine pathname based on router type
- let pathname: string | undefined;
- if (isMemoryRouter) {
- // For memory routers, only use targetPath
- pathname = targetPath;
- } else {
- // For browser routers, use targetPath or fall back to window.location
- pathname = targetPath || WINDOW.location?.pathname;
+ const lazyLoadPromise = (async () => {
+ const result = await originalPatchRoutes(args);
+
+ const currentActiveRootSpan = getActiveRootSpan();
+ if (currentActiveRootSpan && (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation') {
+ const pathname = isMemoryRouter ? targetPath : targetPath || WINDOW.location?.pathname;
+
+ if (pathname) {
+ updateNavigationSpan(
+ currentActiveRootSpan,
+ { pathname, search: '', hash: '', state: null, key: 'default' },
+ Array.from(allRoutes),
+ false,
+ _matchRoutes,
+ );
+ }
}
- if (pathname) {
- updateNavigationSpan(
- activeRootSpan,
- { pathname, search: '', hash: '', state: null, key: 'default' },
- Array.from(allRoutes),
- false, // forceUpdate = false since this is after lazy routes are loaded
- _matchRoutes,
- );
- }
+ return result;
+ })();
+
+ if (activeRootSpan) {
+ trackLazyRouteLoad(activeRootSpan, lazyLoadPromise);
}
- return result;
+ return lazyLoadPromise;
},
};
}
-function getNavigationKey(location: Location): string {
- return `${location.pathname}${location.search}${location.hash}`;
-}
-
-function tryUpdateSpanName(
- activeSpan: Span,
- currentSpanName: string | undefined,
- newName: string,
- newSource: string,
-): void {
- // Check if the new name contains React Router parameter syntax (/:param/)
- const isReactRouterParam = /\/:[a-zA-Z0-9_]+/.test(newName);
- const isNewNameParameterized = newName !== currentSpanName && isReactRouterParam;
- if (isNewNameParameterized) {
- activeSpan.updateName(newName);
- activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, newSource as 'route' | 'url' | 'custom');
- }
-}
-
-function isDuplicateNavigation(client: Client, navigationKey: string): boolean {
- const lastKey = LAST_NAVIGATION_PER_CLIENT.get(client);
- return lastKey === navigationKey;
-}
-
-function createNavigationSpan(opts: {
- client: Client;
- name: string;
- source: string;
- version: string;
- location: Location;
- routes: RouteObject[];
- basename?: string;
- allRoutes?: RouteObject[];
- navigationKey: string;
-}): Span | undefined {
- const { client, name, source, version, location, routes, basename, allRoutes, navigationKey } = opts;
-
- const navigationSpan = startBrowserTracingNavigationSpan(client, {
- name,
- attributes: {
- [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source as 'route' | 'url' | 'custom',
- [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
- [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`,
- },
- });
-
- if (navigationSpan) {
- LAST_NAVIGATION_PER_CLIENT.set(client, navigationKey);
- patchNavigationSpanEnd(navigationSpan, location, routes, basename, allRoutes);
-
- const unsubscribe = client.on('spanEnd', endedSpan => {
- if (endedSpan === navigationSpan) {
- // Clear key only if it's still our key (handles overlapping navigations)
- const lastKey = LAST_NAVIGATION_PER_CLIENT.get(client);
- if (lastKey === navigationKey) {
- LAST_NAVIGATION_PER_CLIENT.delete(client);
- }
- unsubscribe(); // Prevent memory leak
- }
- });
- }
-
- return navigationSpan;
-}
-
+// eslint-disable-next-line complexity
export function handleNavigation(opts: {
location: Location;
routes: RouteObject[];
@@ -714,33 +789,84 @@ export function handleNavigation(opts: {
basename,
);
- const currentNavigationKey = getNavigationKey(location);
- const isNavDuplicate = isDuplicateNavigation(client, currentNavigationKey);
+ const locationKey = computeLocationKey(location);
+ const trackedNav = activeNavigationSpans.get(client);
+
+ // Determine if this navigation should be skipped as a duplicate
+ const trackedSpanHasEnded =
+ trackedNav && !trackedNav.isPlaceholder ? !!spanToJSON(trackedNav.span).timestamp : false;
+ const { skip, shouldUpdate } = shouldSkipNavigation(trackedNav, locationKey, name, trackedSpanHasEnded);
+
+ if (skip) {
+ if (shouldUpdate && trackedNav) {
+ const oldName = trackedNav.routeName;
+
+ if (trackedNav.isPlaceholder) {
+ // Update placeholder's route name - the real span will be created with this name
+ trackedNav.routeName = name;
+ DEBUG_BUILD &&
+ debug.log(
+ `[Tracing] Updated placeholder navigation name from "${oldName}" to "${name}" (will apply to real span)`,
+ );
+ } else {
+ // Update existing real span from wildcard to parameterized route name
+ trackedNav.span.updateName(name);
+ trackedNav.span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source as 'route' | 'url' | 'custom');
+ addNonEnumerableProperty(
+ trackedNav.span as { __sentry_navigation_name_set__?: boolean },
+ '__sentry_navigation_name_set__',
+ true,
+ );
+ trackedNav.routeName = name;
+ DEBUG_BUILD && debug.log(`[Tracing] Updated navigation span name from "${oldName}" to "${name}"`);
+ }
+ } else {
+ DEBUG_BUILD && debug.log(`[Tracing] Skipping duplicate navigation for location: ${locationKey}`);
+ }
+ return;
+ }
- if (isNavDuplicate) {
- // Cross-usage duplicate - update existing span name if better
- const activeSpan = getActiveSpan();
- const spanJson = activeSpan && spanToJSON(activeSpan);
- const isAlreadyInNavigationSpan = spanJson?.op === 'navigation';
+ // Create new navigation span (first navigation or legitimate new navigation)
+ // Reserve the spot in the map first to prevent race conditions
+ // Mark as placeholder to prevent concurrent handleNavigation calls from creating duplicates
+ const placeholderSpan = { end: () => {} } as unknown as Span;
+ const placeholderEntry = {
+ span: placeholderSpan,
+ routeName: name,
+ pathname: location.pathname,
+ locationKey,
+ isPlaceholder: true as const,
+ };
+ activeNavigationSpans.set(client, placeholderEntry);
+
+ let navigationSpan: Span | undefined;
+ try {
+ navigationSpan = startBrowserTracingNavigationSpan(client, {
+ name: placeholderEntry.routeName, // Use placeholder's routeName in case it was updated
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`,
+ },
+ });
+ } catch (e) {
+ // If span creation fails, remove the placeholder so we don't block future navigations
+ activeNavigationSpans.delete(client);
+ throw e;
+ }
- if (isAlreadyInNavigationSpan && activeSpan) {
- tryUpdateSpanName(activeSpan, spanJson?.description, name, source);
- }
- } else {
- // Not a cross-usage duplicate - create new span
- // This handles: different routes, same route with different params (/user/2 → /user/3)
- // startBrowserTracingNavigationSpan will end any active navigation span
- createNavigationSpan({
- client,
- name,
- source,
- version,
- location,
- routes,
- basename,
- allRoutes,
- navigationKey: currentNavigationKey,
+ if (navigationSpan) {
+ // Update the map with the real span (isPlaceholder omitted, defaults to false)
+ activeNavigationSpans.set(client, {
+ span: navigationSpan,
+ routeName: placeholderEntry.routeName, // Use the (potentially updated) placeholder routeName
+ pathname: location.pathname,
+ locationKey,
});
+ patchSpanEnd(navigationSpan, location, routes, basename, allRoutes, 'navigation');
+ } else {
+ // If no span was created, remove the placeholder
+ activeNavigationSpans.delete(client);
}
}
}
@@ -809,11 +935,93 @@ function updatePageloadTransaction({
activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
// Patch span.end() to ensure we update the name one last time before the span is sent
- patchPageloadSpanEnd(activeRootSpan, location, routes, basename, allRoutes);
+ patchSpanEnd(activeRootSpan, location, routes, basename, allRoutes, 'pageload');
}
}
}
+/**
+ * Determines if a span name should be updated during wildcard route resolution.
+ *
+ * Update conditions (in priority order):
+ * 1. No current name + allowNoCurrentName: true → always update (pageload spans)
+ * 2. Current name has wildcard + new is route without wildcard → upgrade (e.g., "/users/*" → "/users/:id")
+ * 3. Current source is not 'route' + new source is 'route' → upgrade (e.g., URL → parameterized route)
+ *
+ * @param currentName - The current span name (may be undefined)
+ * @param currentSource - The current span source ('route', 'url', or undefined)
+ * @param newName - The proposed new span name
+ * @param newSource - The proposed new span source
+ * @param allowNoCurrentName - If true, allow updates when there's no current name (for pageload spans)
+ * @returns true if the span name should be updated
+ */
+function shouldUpdateWildcardSpanName(
+ currentName: string | undefined,
+ currentSource: string | undefined,
+ newName: string,
+ newSource: string,
+ allowNoCurrentName = false,
+): boolean {
+ if (!newName) {
+ return false;
+ }
+
+ if (!currentName && allowNoCurrentName) {
+ return true;
+ }
+
+ const hasWildcard = currentName && transactionNameHasWildcard(currentName);
+
+ if (hasWildcard && newSource === 'route' && !transactionNameHasWildcard(newName)) {
+ return true;
+ }
+
+ if (currentSource !== 'route' && newSource === 'route') {
+ return true;
+ }
+
+ return false;
+}
+
+function tryUpdateSpanNameBeforeEnd(
+ span: Span,
+ spanJson: ReturnType,
+ currentName: string | undefined,
+ location: Location,
+ routes: RouteObject[],
+ basename: string | undefined,
+ spanType: 'pageload' | 'navigation',
+ allRoutes: Set,
+): void {
+ try {
+ const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
+
+ if (currentSource === 'route' && currentName && !transactionNameHasWildcard(currentName)) {
+ return;
+ }
+
+ const currentAllRoutes = Array.from(allRoutes);
+ const routesToUse = currentAllRoutes.length > 0 ? currentAllRoutes : routes;
+ const branches = _matchRoutes(routesToUse, location, basename) as unknown as RouteMatch[];
+
+ if (!branches) {
+ return;
+ }
+
+ const [name, source] = resolveRouteNameAndSource(location, routesToUse, routesToUse, branches, basename);
+
+ const isImprovement = shouldUpdateWildcardSpanName(currentName, currentSource, name, source, true);
+ const spanNotEnded = spanType === 'pageload' || !spanJson.timestamp;
+
+ if (isImprovement && spanNotEnded) {
+ span.updateName(name);
+ span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
+ }
+ } catch (error) {
+ DEBUG_BUILD && debug.warn(`Error updating span details before ending: ${error}`);
+ }
+}
+
/**
* Patches the span.end() method to update the transaction name one last time before the span is sent.
* This handles cases where the span is cancelled early (e.g., document.hidden) before lazy routes have finished loading.
@@ -833,71 +1041,93 @@ function patchSpanEnd(
return;
}
+ // Use the passed route context, or fall back to global Set
+ const allRoutesSet = _allRoutes ? new Set(_allRoutes) : allRoutes;
+
const originalEnd = span.end.bind(span);
+ let endCalled = false;
span.end = function patchedEnd(...args) {
- try {
- // Only update if the span source is not already 'route' (i.e., it hasn't been parameterized yet)
- const spanJson = spanToJSON(span);
- const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
- if (currentSource !== 'route') {
- // Last chance to update the transaction name with the latest route info
- // Use the live global allRoutes Set to include any lazy routes loaded after patching
- const currentAllRoutes = Array.from(allRoutes);
- const branches = _matchRoutes(
- currentAllRoutes.length > 0 ? currentAllRoutes : routes,
- location,
- basename,
- ) as unknown as RouteMatch[];
+ if (endCalled) {
+ return;
+ }
+ endCalled = true;
+
+ // Capture timestamp immediately to avoid delay from async operations
+ // If no timestamp was provided, capture the current time now
+ const endTimestamp = args.length > 0 ? args[0] : Date.now() / 1000;
+
+ const spanJson = spanToJSON(span);
+ const currentName = spanJson.description;
+ const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
+
+ // Helper to clean up activeNavigationSpans after span ends
+ const cleanupNavigationSpan = (): void => {
+ const client = getClient();
+ if (client && spanType === 'navigation') {
+ const trackedNav = activeNavigationSpans.get(client);
+ if (trackedNav && trackedNav.span === span) {
+ activeNavigationSpans.delete(client);
+ }
+ }
+ };
+
+ const pendingPromises = pendingLazyRouteLoads.get(span);
+ // Wait for lazy routes if:
+ // 1. There are pending promises AND
+ // 2. Current name exists AND
+ // 3. Either the name has a wildcard OR the source is not 'route' (URL-based names)
+ const shouldWaitForLazyRoutes =
+ pendingPromises &&
+ pendingPromises.size > 0 &&
+ currentName &&
+ (transactionNameHasWildcard(currentName) || currentSource !== 'route');
+
+ if (shouldWaitForLazyRoutes) {
+ if (_lazyRouteTimeout === 0) {
+ tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutesSet);
+ cleanupNavigationSpan();
+ originalEnd(endTimestamp);
+ return;
+ }
- if (branches) {
- const [name, source] = resolveRouteNameAndSource(
+ const allSettled = Promise.allSettled(pendingPromises).then(() => {});
+ const waitPromise =
+ _lazyRouteTimeout === Infinity
+ ? allSettled
+ : Promise.race([allSettled, new Promise(r => setTimeout(r, _lazyRouteTimeout))]);
+
+ waitPromise
+ .then(() => {
+ const updatedSpanJson = spanToJSON(span);
+ tryUpdateSpanNameBeforeEnd(
+ span,
+ updatedSpanJson,
+ updatedSpanJson.description,
location,
- currentAllRoutes.length > 0 ? currentAllRoutes : routes,
- currentAllRoutes.length > 0 ? currentAllRoutes : routes,
- branches,
+ routes,
basename,
+ spanType,
+ allRoutesSet,
);
-
- // Only update if we have a valid name
- if (name && (spanType === 'pageload' || !spanJson.timestamp)) {
- span.updateName(name);
- span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
- }
- }
- }
- } catch (error) {
- // Silently catch errors to ensure span.end() is always called
- DEBUG_BUILD && debug.warn(`Error updating span details before ending: ${error}`);
+ cleanupNavigationSpan();
+ originalEnd(endTimestamp);
+ })
+ .catch(() => {
+ cleanupNavigationSpan();
+ originalEnd(endTimestamp);
+ });
+ return;
}
- return originalEnd(...args);
+ tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutesSet);
+ cleanupNavigationSpan();
+ originalEnd(endTimestamp);
};
- // Mark this span as having its end() method patched to prevent duplicate patching
addNonEnumerableProperty(span as unknown as Record, patchedPropertyName, true);
}
-function patchPageloadSpanEnd(
- span: Span,
- location: Location,
- routes: RouteObject[],
- basename: string | undefined,
- _allRoutes: RouteObject[] | undefined,
-): void {
- patchSpanEnd(span, location, routes, basename, _allRoutes, 'pageload');
-}
-
-function patchNavigationSpanEnd(
- span: Span,
- location: Location,
- routes: RouteObject[],
- basename: string | undefined,
- _allRoutes: RouteObject[] | undefined,
-): void {
- patchSpanEnd(span, location, routes, basename, _allRoutes, 'navigation');
-}
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function createV6CompatibleWithSentryReactRouterRouting, R extends React.FC
>(
Routes: R,
@@ -933,11 +1163,13 @@ export function createV6CompatibleWithSentryReactRouterRouting
{
return {
...(actual as any),
startBrowserTracingNavigationSpan: vi.fn(),
+ startBrowserTracingPageLoadSpan: vi.fn(),
browserTracingIntegration: vi.fn(() => ({
setup: vi.fn(),
afterAllSetup: vi.fn(),
@@ -49,6 +56,9 @@ vi.mock('../../src/reactrouter-compat-utils/utils', () => ({
getGlobalLocation: vi.fn(() => ({ pathname: '/test', search: '', hash: '' })),
getGlobalPathname: vi.fn(() => '/test'),
routeIsDescendant: vi.fn(() => false),
+ transactionNameHasWildcard: vi.fn((name: string) => {
+ return name.includes('/*') || name === '*' || name.endsWith('*');
+ }),
}));
vi.mock('../../src/reactrouter-compat-utils/lazy-routes', () => ({
@@ -370,3 +380,932 @@ describe('addRoutesToAllRoutes', () => {
expect(firstCount).toBe(secondCount);
});
});
+
+describe('updateNavigationSpan with wildcard detection', () => {
+ const sampleLocation: Location = {
+ pathname: '/test',
+ search: '',
+ hash: '',
+ state: null,
+ key: 'default',
+ };
+
+ const sampleRoutes: RouteObject[] = [
+ { path: '/', element:
Home
},
+ { path: '/about', element: About
},
+ ];
+
+ const mockMatchRoutes = vi.fn(() => []);
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should call updateName when provided with valid routes', () => {
+ const testSpan = { ...mockSpan };
+ updateNavigationSpan(testSpan, sampleLocation, sampleRoutes, false, mockMatchRoutes);
+
+ expect(mockUpdateName).toHaveBeenCalledWith('Test Route');
+ expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route');
+ });
+
+ it('should handle forced updates', () => {
+ const testSpan = { ...mockSpan, __sentry_navigation_name_set__: true };
+ updateNavigationSpan(testSpan, sampleLocation, sampleRoutes, true, mockMatchRoutes);
+
+ // Should update even though already named because forceUpdate=true
+ expect(mockUpdateName).toHaveBeenCalledWith('Test Route');
+ });
+});
+
+describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should upgrade from URL source to route source (regression fix)', async () => {
+ // Setup: Current span has URL source and non-parameterized name
+ vi.mocked(spanToJSON).mockReturnValue({
+ op: 'navigation',
+ description: '/users/123',
+ data: { 'sentry.source': 'url' },
+ } as any);
+
+ // Target: Resolves to route source with parameterized name
+ vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/:id', 'route']);
+
+ const mockUpdateName = vi.fn();
+ const mockSetAttribute = vi.fn();
+ const testSpan = {
+ updateName: mockUpdateName,
+ setAttribute: mockSetAttribute,
+ end: vi.fn(),
+ } as unknown as Span;
+
+ // Simulate patchSpanEnd calling tryUpdateSpanNameBeforeEnd
+ // by updating the span name during a navigation
+ updateNavigationSpan(
+ testSpan,
+ { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' },
+ [{ path: '/users/:id', element: }],
+ false,
+ vi.fn(() => [{ route: { path: '/users/:id' } }]),
+ );
+
+ // Should upgrade from URL to route source
+ expect(mockUpdateName).toHaveBeenCalledWith('/users/:id');
+ expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route');
+ });
+
+ it('should not downgrade from route source to URL source', async () => {
+ // Setup: Current span has route source with parameterized name (no wildcard)
+ vi.mocked(spanToJSON).mockReturnValue({
+ op: 'navigation',
+ description: '/users/:id',
+ data: { 'sentry.source': 'route' },
+ } as any);
+
+ // Target: Would resolve to URL source (downgrade attempt)
+ vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/456', 'url']);
+
+ const mockUpdateName = vi.fn();
+ const mockSetAttribute = vi.fn();
+ const testSpan = {
+ updateName: mockUpdateName,
+ setAttribute: mockSetAttribute,
+ end: vi.fn(),
+ __sentry_navigation_name_set__: true, // Mark as already named
+ } as unknown as Span;
+
+ updateNavigationSpan(
+ testSpan,
+ { pathname: '/users/456', search: '', hash: '', state: null, key: 'test' },
+ [{ path: '/users/:id', element: }],
+ false,
+ vi.fn(() => [{ route: { path: '/users/:id' } }]),
+ );
+
+ // Should not update because span is already named
+ // The early return in tryUpdateSpanNameBeforeEnd protects against downgrades
+ // This test verifies that route->url downgrades are blocked
+ expect(mockUpdateName).not.toHaveBeenCalled();
+ expect(mockSetAttribute).not.toHaveBeenCalled();
+ });
+
+ it('should upgrade wildcard names to specific routes', async () => {
+ // Setup: Current span has route source with wildcard
+ vi.mocked(spanToJSON).mockReturnValue({
+ op: 'navigation',
+ description: '/users/*',
+ data: { 'sentry.source': 'route' },
+ } as any);
+
+ // Mock wildcard detection: current name has wildcard, new name doesn't
+ vi.mocked(transactionNameHasWildcard).mockImplementation((name: string) => {
+ return name === '/users/*'; // Only the current name has wildcard
+ });
+
+ // Target: Resolves to specific parameterized route
+ vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/:id', 'route']);
+
+ const mockUpdateName = vi.fn();
+ const mockSetAttribute = vi.fn();
+ const testSpan = {
+ updateName: mockUpdateName,
+ setAttribute: mockSetAttribute,
+ end: vi.fn(),
+ } as unknown as Span;
+
+ updateNavigationSpan(
+ testSpan,
+ { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' },
+ [{ path: '/users/:id', element: }],
+ false,
+ vi.fn(() => [{ route: { path: '/users/:id' } }]),
+ );
+
+ // Should upgrade from wildcard to specific
+ expect(mockUpdateName).toHaveBeenCalledWith('/users/:id');
+ expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route');
+ });
+
+ it('should not downgrade from wildcard route to URL', async () => {
+ // Setup: Current span has route source with wildcard
+ vi.mocked(spanToJSON).mockReturnValue({
+ op: 'navigation',
+ description: '/users/*',
+ data: { 'sentry.source': 'route' },
+ } as any);
+
+ // Mock wildcard detection: current name has wildcard, new name doesn't
+ vi.mocked(transactionNameHasWildcard).mockImplementation((name: string) => {
+ return name === '/users/*'; // Only the current wildcard name returns true
+ });
+
+ // Target: After timeout, resolves to URL (lazy route didn't finish loading)
+ vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/123', 'url']);
+
+ const mockUpdateName = vi.fn();
+ const mockSetAttribute = vi.fn();
+ const testSpan = {
+ updateName: mockUpdateName,
+ setAttribute: mockSetAttribute,
+ end: vi.fn(),
+ __sentry_navigation_name_set__: true, // Mark span as already named/finalized
+ } as unknown as Span;
+
+ updateNavigationSpan(
+ testSpan,
+ { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' },
+ [{ path: '/users/*', element: }],
+ false,
+ vi.fn(() => [{ route: { path: '/users/*' } }]),
+ );
+
+ // Should not update - keep wildcard route instead of downgrading to URL
+ // Wildcard routes are better than URLs for aggregation in performance monitoring
+ expect(mockUpdateName).not.toHaveBeenCalled();
+ expect(mockSetAttribute).not.toHaveBeenCalled();
+ });
+
+ it('should set name when no current name exists', async () => {
+ // Setup: Current span has no name (undefined)
+ vi.mocked(spanToJSON).mockReturnValue({
+ op: 'navigation',
+ description: undefined,
+ } as any);
+
+ // Target: Resolves to route
+ vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/:id', 'route']);
+
+ const mockUpdateName = vi.fn();
+ const mockSetAttribute = vi.fn();
+ const testSpan = {
+ updateName: mockUpdateName,
+ setAttribute: mockSetAttribute,
+ end: vi.fn(),
+ } as unknown as Span;
+
+ updateNavigationSpan(
+ testSpan,
+ { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' },
+ [{ path: '/users/:id', element: }],
+ false,
+ vi.fn(() => [{ route: { path: '/users/:id' } }]),
+ );
+
+ // Should set initial name
+ expect(mockUpdateName).toHaveBeenCalledWith('/users/:id');
+ expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route');
+ });
+
+ it('should not update when same source and no improvement', async () => {
+ // Setup: Current span has URL source
+ vi.mocked(spanToJSON).mockReturnValue({
+ op: 'navigation',
+ description: '/users/123',
+ data: { 'sentry.source': 'url' },
+ } as any);
+
+ // Target: Resolves to same URL source (no improvement)
+ vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/123', 'url']);
+
+ const mockUpdateName = vi.fn();
+ const mockSetAttribute = vi.fn();
+ const testSpan = {
+ updateName: mockUpdateName,
+ setAttribute: mockSetAttribute,
+ end: vi.fn(),
+ } as unknown as Span;
+
+ updateNavigationSpan(
+ testSpan,
+ { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' },
+ [{ path: '/users/:id', element: }],
+ false,
+ vi.fn(() => [{ route: { path: '/users/:id' } }]),
+ );
+
+ // Note: updateNavigationSpan always updates if not already named
+ // This test validates that the isImprovement logic works correctly in tryUpdateSpanNameBeforeEnd
+ // which is called during span.end() patching
+ expect(mockUpdateName).toHaveBeenCalled(); // Initial set is allowed
+ });
+
+ describe('computeLocationKey (pure function)', () => {
+ it('should include pathname, search, and hash in location key', () => {
+ const location: Location = {
+ pathname: '/search',
+ search: '?q=foo',
+ hash: '#results',
+ state: null,
+ key: 'test',
+ };
+
+ const result = computeLocationKey(location);
+
+ expect(result).toBe('/search?q=foo#results');
+ });
+
+ it('should differentiate locations with same pathname but different query', () => {
+ const loc1: Location = { pathname: '/search', search: '?q=foo', hash: '', state: null, key: 'k1' };
+ const loc2: Location = { pathname: '/search', search: '?q=bar', hash: '', state: null, key: 'k2' };
+
+ const key1 = computeLocationKey(loc1);
+ const key2 = computeLocationKey(loc2);
+
+ // Verifies that search params are included in the location key
+ expect(key1).not.toBe(key2);
+ expect(key1).toBe('/search?q=foo');
+ expect(key2).toBe('/search?q=bar');
+ });
+
+ it('should differentiate locations with same pathname but different hash', () => {
+ const loc1: Location = { pathname: '/page', search: '', hash: '#section1', state: null, key: 'k1' };
+ const loc2: Location = { pathname: '/page', search: '', hash: '#section2', state: null, key: 'k2' };
+
+ const key1 = computeLocationKey(loc1);
+ const key2 = computeLocationKey(loc2);
+
+ // Verifies that hash values are included in the location key
+ expect(key1).not.toBe(key2);
+ expect(key1).toBe('/page#section1');
+ expect(key2).toBe('/page#section2');
+ });
+
+ it('should produce same key for identical locations', () => {
+ const loc1: Location = { pathname: '/users', search: '?id=123', hash: '#profile', state: null, key: 'k1' };
+ const loc2: Location = { pathname: '/users', search: '?id=123', hash: '#profile', state: null, key: 'k2' };
+
+ expect(computeLocationKey(loc1)).toBe(computeLocationKey(loc2));
+ });
+
+ it('should normalize undefined/null search and hash to empty strings (partial location objects)', () => {
+ // When receives a string, React Router creates a partial location
+ // with search: undefined and hash: undefined. We must normalize these to empty strings
+ // to match the keys from full location objects (which have search: '' and hash: '').
+ // This prevents duplicate navigation spans when using prop (common in modal routes).
+ const partialLocation: Location = {
+ pathname: '/users',
+ search: undefined as unknown as string,
+ hash: undefined as unknown as string,
+ state: null,
+ key: 'test1',
+ };
+
+ const fullLocation: Location = {
+ pathname: '/users',
+ search: '',
+ hash: '',
+ state: null,
+ key: 'test2',
+ };
+
+ const partialKey = computeLocationKey(partialLocation);
+ const fullKey = computeLocationKey(fullLocation);
+
+ // Verifies that undefined values are normalized to empty strings, preventing
+ // '/usersundefinedundefined' !== '/users' mismatches
+ expect(partialKey).toBe('/users');
+ expect(fullKey).toBe('/users');
+ expect(partialKey).toBe(fullKey);
+ });
+
+ it('should normalize null search and hash to empty strings', () => {
+ const locationWithNulls: Location = {
+ pathname: '/products',
+ search: null as unknown as string,
+ hash: null as unknown as string,
+ state: null,
+ key: 'test3',
+ };
+
+ const locationWithEmptyStrings: Location = {
+ pathname: '/products',
+ search: '',
+ hash: '',
+ state: null,
+ key: 'test4',
+ };
+
+ expect(computeLocationKey(locationWithNulls)).toBe('/products');
+ expect(computeLocationKey(locationWithEmptyStrings)).toBe('/products');
+ expect(computeLocationKey(locationWithNulls)).toBe(computeLocationKey(locationWithEmptyStrings));
+ });
+ });
+
+ describe('shouldSkipNavigation (pure function - duplicate detection logic)', () => {
+ const mockSpan: Span = { updateName: vi.fn(), setAttribute: vi.fn(), end: vi.fn() } as unknown as Span;
+
+ it('should not skip when no tracked navigation exists', () => {
+ const result = shouldSkipNavigation(undefined, '/users', '/users/:id', false);
+
+ expect(result).toEqual({ skip: false, shouldUpdate: false });
+ });
+
+ it('should skip placeholder navigations for same locationKey', () => {
+ const trackedNav = {
+ span: mockSpan,
+ routeName: '/search',
+ pathname: '/search',
+ locationKey: '/search?q=foo',
+ isPlaceholder: true,
+ };
+
+ const result = shouldSkipNavigation(trackedNav, '/search?q=foo', '/search', false);
+
+ // Verifies that placeholder navigations for the same locationKey are skipped
+ expect(result.skip).toBe(true);
+ expect(result.shouldUpdate).toBe(false);
+ });
+
+ it('should NOT skip placeholder navigations for different locationKey (query change)', () => {
+ const trackedNav = {
+ span: mockSpan,
+ routeName: '/search',
+ pathname: '/search',
+ locationKey: '/search?q=foo',
+ isPlaceholder: true,
+ };
+
+ const result = shouldSkipNavigation(trackedNav, '/search?q=bar', '/search', false);
+
+ // Verifies that different locationKeys allow new navigation even with same pathname
+ expect(result.skip).toBe(false);
+ expect(result.shouldUpdate).toBe(false);
+ });
+
+ it('should skip real span navigations for same locationKey when span has not ended', () => {
+ const trackedNav = {
+ span: mockSpan,
+ routeName: '/users/:id',
+ pathname: '/users/123',
+ locationKey: '/users/123?tab=profile',
+ isPlaceholder: false,
+ };
+
+ const result = shouldSkipNavigation(trackedNav, '/users/123?tab=profile', '/users/:id', false);
+
+ // Verifies that duplicate navigations are blocked when span hasn't ended
+ expect(result.skip).toBe(true);
+ });
+
+ it('should NOT skip real span navigations for different locationKey (query change)', () => {
+ const trackedNav = {
+ span: mockSpan,
+ routeName: '/users/:id',
+ pathname: '/users/123',
+ locationKey: '/users/123?tab=profile',
+ isPlaceholder: false,
+ };
+
+ const result = shouldSkipNavigation(trackedNav, '/users/123?tab=settings', '/users/:id', false);
+
+ // Verifies that different locationKeys allow new navigation even with same pathname
+ expect(result.skip).toBe(false);
+ });
+
+ it('should NOT skip when tracked span has ended', () => {
+ const trackedNav = {
+ span: mockSpan,
+ routeName: '/users/:id',
+ pathname: '/users/123',
+ locationKey: '/users/123',
+ isPlaceholder: false,
+ };
+
+ const result = shouldSkipNavigation(trackedNav, '/users/123', '/users/:id', true);
+
+ // Allow new navigation when previous span has ended
+ expect(result.skip).toBe(false);
+ });
+
+ it('should set shouldUpdate=true for wildcard to parameterized upgrade', () => {
+ const trackedNav = {
+ span: mockSpan,
+ routeName: '/users/*',
+ pathname: '/users/123',
+ locationKey: '/users/123',
+ isPlaceholder: false,
+ };
+
+ const result = shouldSkipNavigation(trackedNav, '/users/123', '/users/:id', false);
+
+ // Verifies that wildcard names are upgraded to parameterized routes
+ expect(result.skip).toBe(true);
+ expect(result.shouldUpdate).toBe(true);
+ });
+
+ it('should NOT set shouldUpdate=true when both names are wildcards', () => {
+ const trackedNav = {
+ span: mockSpan,
+ routeName: '/users/*',
+ pathname: '/users/123',
+ locationKey: '/users/123',
+ isPlaceholder: false,
+ };
+
+ const result = shouldSkipNavigation(trackedNav, '/users/123', '/users/*', false);
+
+ expect(result.skip).toBe(true);
+ expect(result.shouldUpdate).toBe(false);
+ });
+ });
+
+ describe('handleNavigation integration (verifies wiring to pure functions)', () => {
+ // Verifies that handleNavigation correctly uses computeLocationKey and shouldSkipNavigation
+
+ let mockNavigationSpan: Span;
+
+ beforeEach(async () => {
+ // Reset all mocks
+ vi.clearAllMocks();
+
+ // Import fresh modules to reset internal state
+ const coreModule = await import('@sentry/core');
+ const browserModule = await import('@sentry/browser');
+ const instrumentationModule = await import('../../src/reactrouter-compat-utils/instrumentation');
+
+ // Create a mock span with end() that captures callback
+ mockNavigationSpan = {
+ updateName: vi.fn(),
+ setAttribute: vi.fn(),
+ end: vi.fn(),
+ } as unknown as Span;
+
+ // Mock getClient to return a client that's registered for instrumentation
+ const mockClient = {
+ addIntegration: vi.fn(),
+ emit: vi.fn(),
+ on: vi.fn(),
+ getOptions: vi.fn(() => ({})),
+ } as unknown as Client;
+ vi.mocked(coreModule.getClient).mockReturnValue(mockClient);
+
+ // Mock startBrowserTracingPageLoadSpan to avoid pageload span creation during setup
+ vi.mocked(browserModule.startBrowserTracingPageLoadSpan).mockReturnValue(undefined);
+
+ // Register client for instrumentation by adding it to the internal set
+ const integration = instrumentationModule.createReactRouterV6CompatibleTracingIntegration({
+ useEffect: vi.fn(),
+ useLocation: vi.fn(),
+ useNavigationType: vi.fn(),
+ createRoutesFromChildren: vi.fn(),
+ matchRoutes: vi.fn(),
+ });
+ integration.afterAllSetup(mockClient);
+
+ // Mock startBrowserTracingNavigationSpan to return our mock span
+ vi.mocked(browserModule.startBrowserTracingNavigationSpan).mockReturnValue(mockNavigationSpan);
+
+ // Mock spanToJSON to return different values for different calls
+ vi.mocked(coreModule.spanToJSON).mockReturnValue({ op: 'navigation' } as any);
+
+ // Mock getActiveRootSpan to return undefined (no pageload span)
+ vi.mocked(coreModule.getActiveSpan).mockReturnValue(undefined);
+ });
+
+ it('creates navigation span and uses computeLocationKey for tracking', async () => {
+ const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation');
+ const { startBrowserTracingNavigationSpan } = await import('@sentry/browser');
+ const { resolveRouteNameAndSource } = await import('../../src/reactrouter-compat-utils/utils');
+
+ // Mock to return a specific route name
+ vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/search', 'route']);
+
+ const location: Location = {
+ pathname: '/search',
+ search: '?q=foo',
+ hash: '#results',
+ state: null,
+ key: 'test1',
+ };
+
+ const matches = [
+ {
+ pathname: '/search',
+ pathnameBase: '/search',
+ route: { path: '/search', element: },
+ params: {},
+ },
+ ];
+
+ handleNavigation({
+ location,
+ routes: [{ path: '/search', element: }],
+ navigationType: 'PUSH',
+ version: '6' as const,
+ matches: matches as any,
+ });
+
+ // Verifies that handleNavigation calls startBrowserTracingNavigationSpan
+ expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1);
+ expect(startBrowserTracingNavigationSpan).toHaveBeenCalledWith(
+ expect.objectContaining({ emit: expect.any(Function) }), // client
+ expect.objectContaining({
+ name: '/search',
+ attributes: expect.objectContaining({
+ 'sentry.op': 'navigation',
+ 'sentry.source': 'route',
+ }),
+ }),
+ );
+ });
+
+ it('blocks duplicate navigation for exact same locationKey (pathname+query+hash)', async () => {
+ const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation');
+ const { startBrowserTracingNavigationSpan } = await import('@sentry/browser');
+ const { spanToJSON } = await import('@sentry/core');
+
+ const location: Location = {
+ pathname: '/search',
+ search: '?q=foo',
+ hash: '#results',
+ state: null,
+ key: 'test1',
+ };
+
+ const matches = [
+ {
+ pathname: '/search',
+ pathnameBase: '/search',
+ route: { path: '/search', element: },
+ params: {},
+ },
+ ];
+
+ // First navigation - should create span
+ handleNavigation({
+ location,
+ routes: [{ path: '/search', element: }],
+ navigationType: 'PUSH',
+ version: '6' as const,
+ matches: matches as any,
+ });
+
+ // Mock spanToJSON to indicate span hasn't ended yet
+ vi.mocked(spanToJSON).mockReturnValue({ op: 'navigation' } as any);
+
+ // Second navigation - exact same location, should be blocked
+ handleNavigation({
+ location: { ...location, key: 'test2' }, // Different key, same location
+ routes: [{ path: '/search', element: }],
+ navigationType: 'PUSH',
+ version: '6' as const,
+ matches: matches as any,
+ });
+
+ // Verifies that duplicate detection uses locationKey (not just pathname)
+ expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); // Only first call
+ });
+
+ it('allows navigation for same pathname but different query string', async () => {
+ const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation');
+ const { startBrowserTracingNavigationSpan } = await import('@sentry/browser');
+ const { spanToJSON } = await import('@sentry/core');
+
+ const location1: Location = {
+ pathname: '/search',
+ search: '?q=foo',
+ hash: '',
+ state: null,
+ key: 'test1',
+ };
+
+ const matches = [
+ {
+ pathname: '/search',
+ pathnameBase: '/search',
+ route: { path: '/search', element: },
+ params: {},
+ },
+ ];
+
+ // First navigation
+ handleNavigation({
+ location: location1,
+ routes: [{ path: '/search', element: }],
+ navigationType: 'PUSH',
+ version: '6' as const,
+ matches: matches as any,
+ });
+
+ // Mock spanToJSON to indicate span hasn't ended yet
+ vi.mocked(spanToJSON).mockReturnValue({ op: 'navigation' } as any);
+
+ // Second navigation - same pathname, different query
+ const location2: Location = {
+ pathname: '/search',
+ search: '?q=bar',
+ hash: '',
+ state: null,
+ key: 'test2',
+ };
+
+ handleNavigation({
+ location: location2,
+ routes: [{ path: '/search', element: }],
+ navigationType: 'PUSH',
+ version: '6' as const,
+ matches: matches as any,
+ });
+
+ // Verifies that query params are included in locationKey for duplicate detection
+ expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); // Both calls should create spans
+ });
+
+ it('allows navigation for same pathname but different hash', async () => {
+ const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation');
+ const { startBrowserTracingNavigationSpan } = await import('@sentry/browser');
+ const { spanToJSON } = await import('@sentry/core');
+
+ const location1: Location = {
+ pathname: '/page',
+ search: '',
+ hash: '#section1',
+ state: null,
+ key: 'test1',
+ };
+
+ const matches = [
+ {
+ pathname: '/page',
+ pathnameBase: '/page',
+ route: { path: '/page', element: },
+ params: {},
+ },
+ ];
+
+ // First navigation
+ handleNavigation({
+ location: location1,
+ routes: [{ path: '/page', element: }],
+ navigationType: 'PUSH',
+ version: '6' as const,
+ matches: matches as any,
+ });
+
+ // Mock spanToJSON to indicate span hasn't ended yet
+ vi.mocked(spanToJSON).mockReturnValue({ op: 'navigation' } as any);
+
+ // Second navigation - same pathname, different hash
+ const location2: Location = {
+ pathname: '/page',
+ search: '',
+ hash: '#section2',
+ state: null,
+ key: 'test2',
+ };
+
+ handleNavigation({
+ location: location2,
+ routes: [{ path: '/page', element: }],
+ navigationType: 'PUSH',
+ version: '6' as const,
+ matches: matches as any,
+ });
+
+ // Verifies that hash values are included in locationKey for duplicate detection
+ expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); // Both calls should create spans
+ });
+
+ it('updates wildcard span when better parameterized name becomes available', async () => {
+ const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation');
+ const { startBrowserTracingNavigationSpan } = await import('@sentry/browser');
+ const { spanToJSON } = await import('@sentry/core');
+ const { transactionNameHasWildcard, resolveRouteNameAndSource } = await import(
+ '../../src/reactrouter-compat-utils/utils'
+ );
+
+ const location: Location = {
+ pathname: '/users/123',
+ search: '',
+ hash: '',
+ state: null,
+ key: 'test1',
+ };
+
+ const matches = [
+ {
+ pathname: '/users/123',
+ pathnameBase: '/users',
+ route: { path: '/users/*', element: },
+ params: { '*': '123' },
+ },
+ ];
+
+ // First navigation - resolves to wildcard name
+ vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/*', 'route']);
+ // Mock transactionNameHasWildcard to return true for wildcards, false for parameterized
+ vi.mocked(transactionNameHasWildcard).mockImplementation((name: string) => {
+ return name.includes('/*') || name === '*' || name.endsWith('*');
+ });
+
+ handleNavigation({
+ location,
+ routes: [{ path: '/users/*', element: }],
+ navigationType: 'PUSH',
+ version: '6' as const,
+ matches: matches as any,
+ });
+
+ const firstSpan = mockNavigationSpan;
+ expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1);
+
+ // Mock spanToJSON to indicate span hasn't ended yet and has wildcard name
+ vi.mocked(spanToJSON).mockReturnValue({
+ op: 'navigation',
+ description: '/users/*',
+ data: { 'sentry.source': 'route' },
+ } as any);
+
+ // Second navigation - same location but better parameterized name available
+ vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/:id', 'route']);
+
+ handleNavigation({
+ location: { ...location, key: 'test2' },
+ routes: [{ path: '/users/:id', element: }],
+ navigationType: 'PUSH',
+ version: '6' as const,
+ matches: matches as any,
+ });
+
+ // Verifies that wildcard span names are upgraded when parameterized routes become available
+ // eslint-disable-next-line @typescript-eslint/unbound-method
+ expect(vi.mocked(firstSpan.updateName)).toHaveBeenCalledWith('/users/:id');
+ expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); // No new span created
+ });
+
+ it('prevents duplicate spans when prop is a string (partial location)', async () => {
+ // This test verifies the fix for the bug where creates
+ // a partial location object with search: undefined and hash: undefined, which
+ // would result in a different locationKey ('/usersundefinedundefined' vs '/users')
+ // causing duplicate navigation spans.
+ const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation');
+ const { startBrowserTracingNavigationSpan } = await import('@sentry/browser');
+ const { spanToJSON } = await import('@sentry/core');
+ const { resolveRouteNameAndSource } = await import('../../src/reactrouter-compat-utils/utils');
+
+ // Mock resolveRouteNameAndSource to return consistent route name
+ vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users', 'route']);
+
+ const matches = [
+ {
+ pathname: '/users',
+ pathnameBase: '/users',
+ route: { path: '/users', element: },
+ params: {},
+ },
+ ];
+
+ // First call: Partial location (from )
+ // React Router creates location with undefined search and hash
+ const partialLocation: Location = {
+ pathname: '/users',
+ search: undefined as unknown as string,
+ hash: undefined as unknown as string,
+ state: null,
+ key: 'test1',
+ };
+
+ handleNavigation({
+ location: partialLocation,
+ routes: [{ path: '/users', element: }],
+ navigationType: 'PUSH',
+ version: '6' as const,
+ matches: matches as any,
+ });
+
+ expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1);
+
+ // Mock spanToJSON to indicate span hasn't ended yet
+ vi.mocked(spanToJSON).mockReturnValue({ op: 'navigation' } as any);
+
+ // Second call: Full location (from router.state)
+ // React Router provides location with empty string search and hash
+ const fullLocation: Location = {
+ pathname: '/users',
+ search: '',
+ hash: '',
+ state: null,
+ key: 'test2',
+ };
+
+ handleNavigation({
+ location: fullLocation,
+ routes: [{ path: '/users', element: }],
+ navigationType: 'PUSH',
+ version: '6' as const,
+ matches: matches as any,
+ });
+
+ // Verifies that undefined values are normalized, preventing duplicate spans
+ // (without normalization, '/usersundefinedundefined' != '/users' would create 2 spans)
+ expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('SSR-safe RAF fallback (scheduleCallback/cancelScheduledCallback)', () => {
+ // These tests verify that the RAF fallback works correctly in SSR environments
+
+ it('uses requestAnimationFrame when available', () => {
+ // Save original RAF
+ const originalRAF = window.requestAnimationFrame;
+ const rafSpy = vi.fn((cb: () => void) => {
+ cb();
+ return 123;
+ });
+ window.requestAnimationFrame = rafSpy;
+
+ try {
+ // Import module to trigger RAF usage
+ const scheduleCallback = (callback: () => void): number => {
+ if (window?.requestAnimationFrame) {
+ return window.requestAnimationFrame(callback);
+ }
+ return setTimeout(callback, 0) as unknown as number;
+ };
+
+ const mockCallback = vi.fn();
+ scheduleCallback(mockCallback);
+
+ // Verifies that requestAnimationFrame is used when available
+ expect(rafSpy).toHaveBeenCalled();
+ expect(mockCallback).toHaveBeenCalled();
+ } finally {
+ window.requestAnimationFrame = originalRAF;
+ }
+ });
+
+ it('falls back to setTimeout when requestAnimationFrame is unavailable (SSR)', () => {
+ // Simulate SSR by removing RAF
+ const originalRAF = window.requestAnimationFrame;
+ const originalCAF = window.cancelAnimationFrame;
+ // @ts-expect-error - Simulating SSR environment
+ delete window.requestAnimationFrame;
+ // @ts-expect-error - Simulating SSR environment
+ delete window.cancelAnimationFrame;
+
+ try {
+ const timeoutSpy = vi.spyOn(global, 'setTimeout');
+
+ // Import module to trigger setTimeout fallback
+ const scheduleCallback = (callback: () => void): number => {
+ if (window?.requestAnimationFrame) {
+ return window.requestAnimationFrame(callback);
+ }
+ return setTimeout(callback, 0) as unknown as number;
+ };
+
+ const mockCallback = vi.fn();
+ scheduleCallback(mockCallback);
+
+ // Verifies that setTimeout is used when requestAnimationFrame is unavailable
+ expect(timeoutSpy).toHaveBeenCalledWith(mockCallback, 0);
+ } finally {
+ window.requestAnimationFrame = originalRAF;
+ window.cancelAnimationFrame = originalCAF;
+ }
+ });
+ });
+});
diff --git a/packages/react/test/reactrouter-compat-utils/utils.test.ts b/packages/react/test/reactrouter-compat-utils/utils.test.ts
index 9ff48e7450bc..438b026104bd 100644
--- a/packages/react/test/reactrouter-compat-utils/utils.test.ts
+++ b/packages/react/test/reactrouter-compat-utils/utils.test.ts
@@ -9,6 +9,7 @@ import {
prefixWithSlash,
rebuildRoutePathFromAllRoutes,
resolveRouteNameAndSource,
+ transactionNameHasWildcard,
} from '../../src/reactrouter-compat-utils';
import type { Location, MatchRoutes, RouteMatch, RouteObject } from '../../src/types';
@@ -629,4 +630,38 @@ describe('reactrouter-compat-utils/utils', () => {
expect(result).toEqual(['/unknown', 'url']);
});
});
+
+ describe('transactionNameHasWildcard', () => {
+ it('should detect wildcard at the end of path', () => {
+ expect(transactionNameHasWildcard('/lazy/*')).toBe(true);
+ expect(transactionNameHasWildcard('/users/:id/*')).toBe(true);
+ expect(transactionNameHasWildcard('/products/:category/*')).toBe(true);
+ });
+
+ it('should detect standalone wildcard', () => {
+ expect(transactionNameHasWildcard('*')).toBe(true);
+ });
+
+ it('should detect wildcard in the middle of path', () => {
+ expect(transactionNameHasWildcard('/lazy/*/nested')).toBe(true);
+ expect(transactionNameHasWildcard('/a/*/b/*/c')).toBe(true);
+ });
+
+ it('should not detect wildcards in parameterized routes', () => {
+ expect(transactionNameHasWildcard('/users/:id')).toBe(false);
+ expect(transactionNameHasWildcard('/products/:category/:id')).toBe(false);
+ expect(transactionNameHasWildcard('/items/:itemId/details')).toBe(false);
+ });
+
+ it('should not detect wildcards in static routes', () => {
+ expect(transactionNameHasWildcard('/')).toBe(false);
+ expect(transactionNameHasWildcard('/about')).toBe(false);
+ expect(transactionNameHasWildcard('/users/profile')).toBe(false);
+ });
+
+ it('should handle edge cases', () => {
+ expect(transactionNameHasWildcard('')).toBe(false);
+ expect(transactionNameHasWildcard('/path/to/asterisk')).toBe(false); // 'asterisk' contains 'isk' but not '*'
+ });
+ });
});