Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
809b2b8
ref(react): Add more guarding against leftover wildcards in lazy rout…
onurtemizkan Nov 11, 2025
3f10bf6
Fix lockfile
onurtemizkan Nov 11, 2025
efb5a74
Add configurable lazy route timeout and fix url paths
onurtemizkan Nov 11, 2025
0bb2bc4
Optimize for bundle size
onurtemizkan Nov 11, 2025
97bb7cc
Do not downgrade from wildcard route to unparameterized route
onurtemizkan Nov 12, 2025
c58396a
Update packages/react/src/reactrouter-compat-utils/instrumentation.tsx
onurtemizkan Nov 13, 2025
1a7df52
Address review comments
onurtemizkan Nov 13, 2025
7966e3f
Address copilot review
onurtemizkan Nov 13, 2025
0a84ffd
Extract all update decision logic to `shouldUpdateWildcardSpanName`
onurtemizkan Nov 14, 2025
d700f3a
Fix duplication
onurtemizkan Nov 17, 2025
be8a668
Prevent stale navigation spans from blocking legitimate navigations
onurtemizkan Nov 17, 2025
f595c94
Fix the debug log.
onurtemizkan Nov 17, 2025
5c0d2f0
Address copilot review
onurtemizkan Nov 17, 2025
66164ac
Utilize passed allRoutes in patched spanEnd
onurtemizkan Nov 17, 2025
d669065
Reduce complexity, cleanup
onurtemizkan Nov 17, 2025
420e93d
Set `__sentry_navigation_name_set__ `
onurtemizkan Nov 17, 2025
15f8719
Add E2E tests
onurtemizkan Nov 20, 2025
a65f1ef
Merge branch 'develop' into onur/more-guarding-against-wildcard-trans…
onurtemizkan Nov 20, 2025
4fdf8d0
Clean up / cover raw to parameterized span ugrades
onurtemizkan Nov 20, 2025
f6c87eb
Capture span end timestamp immediately to avoid lazy route delay
onurtemizkan Nov 20, 2025
1424ee1
Address copilot suggestions.
onurtemizkan Nov 20, 2025
b88f92e
Merge branch 'develop' into onur/more-guarding-against-wildcard-trans…
onurtemizkan Nov 20, 2025
d98579c
Increase the size-limit for react + tracing to 45 KB
onurtemizkan Nov 21, 2025
4beb00c
Merge branch 'develop' into onur/more-guarding-against-wildcard-trans…
onurtemizkan Nov 21, 2025
457624a
Cap `laztRouteTimeout` to `finalTimeout`
onurtemizkan Nov 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -66,8 +110,21 @@ const router = sentryCreateBrowserRouter(
element: <>Hello World</>,
},
{
path: '*',
element: <Navigate to="/" replace />,
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: <Deep />,
handle: {
lazyChildren: () => import('./pages/deep/Level1Routes').then(module => module.level2Routes),
},
},
],
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import React from 'react';
import { Outlet } from 'react-router-dom';

export default function Deep() {
return (
<div>
<h1>Deep Route Root</h1>
<p id="deep-root">You are at the deep route root</p>
<Outlet />
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<div id="delayed-lazy-ready">
<h1>Delayed Lazy Route</h1>
<p id="delayed-lazy-id">ID: {id}</p>
<p id="delayed-lazy-path">{location.pathname}</p>
<p id="delayed-lazy-search">{location.search}</p>
<p id="delayed-lazy-hash">{location.hash}</p>
<p id="delayed-lazy-view">View: {view}</p>
<p id="delayed-lazy-source">Source: {source}</p>

<div id="navigation-links">
<Link to="/" id="delayed-lazy-home-link">
Back Home
</Link>
<br />
<Link to={`/delayed-lazy/${id}?view=detailed`} id="link-to-query-view-detailed">
View: Detailed (query param)
</Link>
<br />
<Link to={`/delayed-lazy/${id}?view=list`} id="link-to-query-view-list">
View: List (query param)
</Link>
<br />
<Link to={`/delayed-lazy/${id}#section1`} id="link-to-hash-section1">
Section 1 (hash only)
</Link>
<br />
<Link to={`/delayed-lazy/${id}#section2`} id="link-to-hash-section2">
Section 2 (hash only)
</Link>
<br />
<Link to={`/delayed-lazy/${id}?view=grid#results`} id="link-to-query-and-hash">
Grid View + Results (query + hash)
</Link>
</div>
</div>
);
};

export default DelayedLazyRoute;
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ const Index = () => {
<Link to="/long-running/slow/12345" id="navigation-to-long-running">
Navigate to Long Running Lazy Route
</Link>
<br />
<Link to="/delayed-lazy/123" id="navigation-to-delayed-lazy">
Navigate to Delayed Lazy Parameterized Route
</Link>
<br />
<Link to="/delayed-lazy/123?source=homepage" id="navigation-to-delayed-lazy-with-query">
Navigate to Delayed Lazy with Query Param
</Link>
<br />
<Link to="/deep/level2/level3/123" id="navigation-to-deep">
Navigate to Deep Nested Route (3 levels, 900ms total)
</Link>
</>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
},
},
];
Original file line number Diff line number Diff line change
@@ -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,
};
},
},
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react';
import { useParams } from 'react-router-dom';

export default function Level3() {
const { id } = useParams();
return (
<div>
<h1>Level 3 Deep Route</h1>
<p id="deep-level3">Deeply nested route loaded!</p>
<p id="deep-level3-id">ID: {id}</p>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 however long needed
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 indefinitely 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');
});
Loading
Loading