Skip to content

Commit 0e38431

Browse files
committed
feat(react): Add lazyRouteManifest option to resolve lazy-route names
1 parent 70e9cf9 commit 0e38431

8 files changed

Lines changed: 766 additions & 2 deletions

File tree

dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,26 @@ function getRuntimeConfig(): { lazyRouteTimeout?: number; idleTimeout?: number }
5555

5656
const runtimeConfig = getRuntimeConfig();
5757

58+
// Static manifest for transaction naming when lazy routes are enabled
59+
const lazyRouteManifest = [
60+
'/',
61+
'/static',
62+
'/delayed-lazy/:id',
63+
'/lazy/inner',
64+
'/lazy/inner/:id',
65+
'/lazy/inner/:id/:anotherId',
66+
'/lazy/inner/:id/:anotherId/:someAnotherId',
67+
'/another-lazy/sub',
68+
'/another-lazy/sub/:id',
69+
'/another-lazy/sub/:id/:subId',
70+
'/long-running/slow',
71+
'/long-running/slow/:id',
72+
'/deep/level2',
73+
'/deep/level2/level3/:id',
74+
'/slow-fetch/:id',
75+
'/wildcard-lazy/:id',
76+
];
77+
5878
Sentry.init({
5979
environment: 'qa', // dynamic sampling bias to keep transactions
6080
dsn: process.env.REACT_APP_E2E_TEST_DSN,
@@ -69,6 +89,7 @@ Sentry.init({
6989
enableAsyncRouteHandlers: true,
7090
lazyRouteTimeout: runtimeConfig.lazyRouteTimeout,
7191
idleTimeout: runtimeConfig.idleTimeout,
92+
lazyRouteManifest,
7293
}),
7394
],
7495
// We recommend adjusting this value in production, or using tracesSampler
@@ -160,5 +181,15 @@ const router = sentryCreateBrowserRouter(
160181
},
161182
);
162183

184+
// E2E TEST UTILITY: Expose router instance for canary tests
185+
// This allows tests to verify React Router's route exposure behavior.
186+
// See tests/react-router-manifest.test.ts for usage.
187+
declare global {
188+
interface Window {
189+
__REACT_ROUTER__: typeof router;
190+
}
191+
}
192+
window.__REACT_ROUTER__ = router;
193+
163194
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
164195
root.render(<RouterProvider router={router} />);
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { expect, test, Page } from '@playwright/test';
2+
3+
/**
4+
* Canary tests: React Router route manifest exposure
5+
*
6+
* These tests verify that React Router doesn't expose lazy-loaded routes in `router.routes`
7+
* before navigation completes. They will fail when React Router changes this behavior.
8+
*
9+
* - Tests pass when React Router doesn't expose lazy routes (current behavior)
10+
* - Tests fail when React Router does expose lazy routes (future behavior)
11+
*
12+
* If these tests fail, React Router may now expose lazy routes natively, and the
13+
* `lazyRouteManifest` workaround might no longer be needed. Check React Router's changelog
14+
* and consider updating the SDK to use native route exposure.
15+
*
16+
* Note: `router.routes` is the documented way to access routes when using RouterProvider.
17+
* See: https://github.com/remix-run/react-router/discussions/10857
18+
*/
19+
20+
/**
21+
* Extracts all route paths from the React Router instance exposed on window.__REACT_ROUTER__.
22+
* Recursively traverses the route tree and builds full path strings.
23+
*/
24+
async function extractRoutePaths(page: Page): Promise<string[]> {
25+
return page.evaluate(() => {
26+
const router = (window as Record<string, unknown>).__REACT_ROUTER__ as
27+
| { routes?: Array<{ path?: string; children?: unknown[] }> }
28+
| undefined;
29+
if (!router?.routes) return [];
30+
31+
const paths: string[] = [];
32+
function traverse(routes: Array<{ path?: string; children?: unknown[] }>, parent = ''): void {
33+
for (const r of routes) {
34+
const full = r.path ? (r.path.startsWith('/') ? r.path : `${parent}/${r.path}`) : parent;
35+
if (r.path) paths.push(full);
36+
if (r.children) traverse(r.children as Array<{ path?: string; children?: unknown[] }>, full);
37+
}
38+
}
39+
traverse(router.routes);
40+
return paths;
41+
});
42+
}
43+
44+
test.describe('[CANARY] React Router Route Manifest Exposure', () => {
45+
/**
46+
* Verifies that lazy routes are not pre-populated in router.routes.
47+
* If lazy routes appear in the initial route tree, React Router has changed behavior.
48+
*/
49+
test('React Router should not expose lazy routes before lazy handler resolves', async ({ page }) => {
50+
await page.goto('/');
51+
await page.waitForTimeout(500);
52+
53+
const initialRoutes = await extractRoutePaths(page);
54+
const hasSlowFetchInitially = initialRoutes.some(p => p.includes('/slow-fetch/:id'));
55+
56+
// Test passes if routes are not available initially (we need the workaround)
57+
// Test fails if routes are available initially (workaround may not be needed!)
58+
expect(
59+
hasSlowFetchInitially,
60+
`
61+
React Router now exposes lazy routes in the initial route tree!
62+
This means the lazyRouteManifest workaround may no longer be needed.
63+
64+
Initial routes: ${JSON.stringify(initialRoutes, null, 2)}
65+
66+
Next steps:
67+
1. Verify this behavior is consistent and intentional
68+
2. Check React Router changelog for details
69+
3. Consider removing the lazyRouteManifest workaround
70+
`,
71+
).toBe(false);
72+
});
73+
74+
/**
75+
* Verifies that lazy route children are not in router.routes before visiting them.
76+
*/
77+
test('React Router should not have lazy route children before visiting them', async ({ page }) => {
78+
await page.goto('/');
79+
await page.waitForTimeout(300);
80+
81+
const routes = await extractRoutePaths(page);
82+
const hasLazyChildren = routes.some(
83+
p =>
84+
p.includes('/lazy/inner/:id') ||
85+
p.includes('/another-lazy/sub/:id') ||
86+
p.includes('/slow-fetch/:id') ||
87+
p.includes('/deep/level2/level3/:id'),
88+
);
89+
90+
// Test passes if lazy children are not in routes before visiting (we need the workaround)
91+
// Test fails if lazy children are in routes before visiting (workaround may not be needed!)
92+
expect(
93+
hasLazyChildren,
94+
`
95+
React Router now includes lazy route children in router.routes upfront!
96+
This means the lazyRouteManifest workaround may no longer be needed.
97+
98+
Routes at home page: ${JSON.stringify(routes, null, 2)}
99+
100+
Next steps:
101+
1. Verify this behavior is consistent and intentional
102+
2. Check React Router changelog for details
103+
3. Consider removing the lazyRouteManifest workaround
104+
`,
105+
).toBe(false);
106+
});
107+
});

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1433,3 +1433,54 @@ test('Second navigation span is not corrupted by first slow lazy handler complet
14331433
expect(wrongSpans.length).toBe(0);
14341434
}
14351435
});
1436+
1437+
// lazyRouteManifest: provides parameterized name when lazy routes don't resolve in time
1438+
test('Route manifest provides correct name when navigation span ends before lazy route resolves', async ({ page }) => {
1439+
// Short idle timeout (50ms) ensures span ends before lazy route (500ms) resolves
1440+
await page.goto('/?idleTimeout=50&timeout=0');
1441+
1442+
// Wait for pageload to complete
1443+
await page.waitForTimeout(200);
1444+
1445+
const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
1446+
return (
1447+
!!transactionEvent?.transaction &&
1448+
transactionEvent.contexts?.trace?.op === 'navigation' &&
1449+
(transactionEvent.transaction?.startsWith('/wildcard-lazy') ?? false)
1450+
);
1451+
});
1452+
1453+
// Navigate to wildcard-lazy route (500ms delay in module via top-level await)
1454+
const wildcardLazyLink = page.locator('id=navigation-to-wildcard-lazy');
1455+
await expect(wildcardLazyLink).toBeVisible();
1456+
await wildcardLazyLink.click();
1457+
1458+
const event = await navigationPromise;
1459+
1460+
// Should have parameterized name from manifest, not wildcard (/wildcard-lazy/*)
1461+
expect(event.transaction).toBe('/wildcard-lazy/:id');
1462+
expect(event.type).toBe('transaction');
1463+
expect(event.contexts?.trace?.op).toBe('navigation');
1464+
expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route');
1465+
});
1466+
1467+
test('Route manifest provides correct name when pageload span ends before lazy route resolves', async ({ page }) => {
1468+
// Short idle timeout (50ms) ensures span ends before lazy route (500ms) resolves
1469+
const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
1470+
return (
1471+
!!transactionEvent?.transaction &&
1472+
transactionEvent.contexts?.trace?.op === 'pageload' &&
1473+
(transactionEvent.transaction?.startsWith('/wildcard-lazy') ?? false)
1474+
);
1475+
});
1476+
1477+
await page.goto('/wildcard-lazy/123?idleTimeout=50&timeout=0');
1478+
1479+
const event = await pageloadPromise;
1480+
1481+
// Should have parameterized name from manifest, not wildcard (/wildcard-lazy/*)
1482+
expect(event.transaction).toBe('/wildcard-lazy/:id');
1483+
expect(event.type).toBe('transaction');
1484+
expect(event.contexts?.trace?.op).toBe('pageload');
1485+
expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route');
1486+
});

packages/react/src/reactrouter-compat-utils/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,6 @@ export {
3535

3636
// Lazy route exports
3737
export { createAsyncHandlerProxy, handleAsyncHandlerResult, checkRouteForAsyncHandler } from './lazy-routes';
38+
39+
// Route manifest exports
40+
export { matchRouteManifest } from './route-manifest';

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

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ let _matchRoutes: MatchRoutes;
5656

5757
let _enableAsyncRouteHandlers: boolean = false;
5858
let _lazyRouteTimeout = 3000;
59+
let _lazyRouteManifest: string[] | undefined;
60+
let _basename: string = '';
5961

6062
const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet<Client>();
6163

@@ -196,6 +198,25 @@ export interface ReactRouterOptions {
196198
* @default idleTimeout * 3
197199
*/
198200
lazyRouteTimeout?: number;
201+
202+
/**
203+
* Static route manifest for resolving parameterized route names with lazy routes.
204+
*
205+
* Requires `enableAsyncRouteHandlers: true`. When provided, the manifest is used
206+
* as the primary source for determining transaction names. This is more reliable
207+
* than depending on React Router's lazy route resolution timing.
208+
*
209+
* @example
210+
* ```ts
211+
* lazyRouteManifest: [
212+
* '/',
213+
* '/users',
214+
* '/users/:userId',
215+
* '/org/:orgSlug/projects/:projectId',
216+
* ]
217+
* ```
218+
*/
219+
lazyRouteManifest?: string[];
199220
}
200221

201222
type V6CompatibleVersion = '6' | '7';
@@ -355,7 +376,9 @@ export function updateNavigationSpan(
355376
allRoutes,
356377
allRoutes,
357378
(currentBranches as RouteMatch[]) || [],
358-
'',
379+
_basename,
380+
_lazyRouteManifest,
381+
_enableAsyncRouteHandlers,
359382
);
360383

361384
const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
@@ -520,6 +543,9 @@ export function createV6CompatibleWrapCreateBrowserRouter<
520543
});
521544
}
522545

546+
// Store basename for use in updateNavigationSpan
547+
_basename = basename || '';
548+
523549
setupRouterSubscription(router, routes, version, basename, activeRootSpan);
524550

525551
return router;
@@ -614,6 +640,9 @@ export function createV6CompatibleWrapCreateMemoryRouter<
614640
});
615641
}
616642

643+
// Store basename for use in updateNavigationSpan
644+
_basename = basename || '';
645+
617646
setupRouterSubscription(router, routes, version, basename, memoryActiveRootSpan);
618647

619648
return router;
@@ -640,6 +669,7 @@ export function createReactRouterV6CompatibleTracingIntegration(
640669
instrumentPageLoad = true,
641670
instrumentNavigation = true,
642671
lazyRouteTimeout,
672+
lazyRouteManifest,
643673
} = options;
644674

645675
return {
@@ -683,6 +713,7 @@ export function createReactRouterV6CompatibleTracingIntegration(
683713
_matchRoutes = matchRoutes;
684714
_createRoutesFromChildren = createRoutesFromChildren;
685715
_enableAsyncRouteHandlers = enableAsyncRouteHandlers;
716+
_lazyRouteManifest = lazyRouteManifest;
686717

687718
// Initialize the router utils with the required dependencies
688719
initializeRouterUtils(matchRoutes, stripBasename || false);
@@ -932,6 +963,8 @@ export function handleNavigation(opts: {
932963
allRoutes || routes,
933964
branches as RouteMatch[],
934965
basename,
966+
_lazyRouteManifest,
967+
_enableAsyncRouteHandlers,
935968
);
936969

937970
const locationKey = computeLocationKey(location);
@@ -1071,6 +1104,8 @@ function updatePageloadTransaction({
10711104
allRoutes || routes,
10721105
branches,
10731106
basename,
1107+
_lazyRouteManifest,
1108+
_enableAsyncRouteHandlers,
10741109
);
10751110

10761111
getCurrentScope().setTransactionName(name || '/');
@@ -1158,7 +1193,15 @@ function tryUpdateSpanNameBeforeEnd(
11581193
return;
11591194
}
11601195

1161-
const [name, source] = resolveRouteNameAndSource(location, routesToUse, routesToUse, branches, basename);
1196+
const [name, source] = resolveRouteNameAndSource(
1197+
location,
1198+
routesToUse,
1199+
routesToUse,
1200+
branches,
1201+
basename,
1202+
_lazyRouteManifest,
1203+
_enableAsyncRouteHandlers,
1204+
);
11621205

11631206
const isImprovement = shouldUpdateWildcardSpanName(currentName, currentSource, name, source, true);
11641207
const spanNotEnded = spanType === 'pageload' || !spanJson.timestamp;

0 commit comments

Comments
 (0)