Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -55,6 +55,26 @@ function getRuntimeConfig(): { lazyRouteTimeout?: number; idleTimeout?: number }

const runtimeConfig = getRuntimeConfig();

// Static manifest for transaction naming when lazy routes are enabled
const lazyRouteManifest = [
'/',
'/static',
'/delayed-lazy/:id',
'/lazy/inner',
'/lazy/inner/:id',
'/lazy/inner/:id/:anotherId',
'/lazy/inner/:id/:anotherId/:someAnotherId',
'/another-lazy/sub',
'/another-lazy/sub/:id',
'/another-lazy/sub/:id/:subId',
'/long-running/slow',
'/long-running/slow/:id',
'/deep/level2',
'/deep/level2/level3/:id',
'/slow-fetch/:id',
'/wildcard-lazy/:id',
];

Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: process.env.REACT_APP_E2E_TEST_DSN,
Expand All @@ -69,6 +89,7 @@ Sentry.init({
enableAsyncRouteHandlers: true,
lazyRouteTimeout: runtimeConfig.lazyRouteTimeout,
idleTimeout: runtimeConfig.idleTimeout,
lazyRouteManifest,
}),
],
// We recommend adjusting this value in production, or using tracesSampler
Expand Down Expand Up @@ -160,5 +181,15 @@ const router = sentryCreateBrowserRouter(
},
);

// E2E TEST UTILITY: Expose router instance for canary tests
// This allows tests to verify React Router's route exposure behavior.
// See tests/react-router-manifest.test.ts for usage.
declare global {
interface Window {
__REACT_ROUTER__: typeof router;
}
}
window.__REACT_ROUTER__ = router;

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(<RouterProvider router={router} />);
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { expect, test, Page } from '@playwright/test';

/**
* Canary tests: React Router route manifest exposure
*
* These tests verify that React Router doesn't expose lazy-loaded routes in `router.routes`
* before navigation completes. They will fail when React Router changes this behavior.
*
* - Tests pass when React Router doesn't expose lazy routes (current behavior)
* - Tests fail when React Router does expose lazy routes (future behavior)
*
* If these tests fail, React Router may now expose lazy routes natively, and the
* `lazyRouteManifest` workaround might no longer be needed. Check React Router's changelog
* and consider updating the SDK to use native route exposure.
*
* Note: `router.routes` is the documented way to access routes when using RouterProvider.
* See: https://github.com/remix-run/react-router/discussions/10857
*/

/**
* Extracts all route paths from the React Router instance exposed on window.__REACT_ROUTER__.
* Recursively traverses the route tree and builds full path strings.
*/
async function extractRoutePaths(page: Page): Promise<string[]> {
return page.evaluate(() => {
const router = (window as Record<string, unknown>).__REACT_ROUTER__ as
| { routes?: Array<{ path?: string; children?: unknown[] }> }
| undefined;
if (!router?.routes) return [];

const paths: string[] = [];
function traverse(routes: Array<{ path?: string; children?: unknown[] }>, parent = ''): void {
for (const r of routes) {
const full = r.path ? (r.path.startsWith('/') ? r.path : `${parent}/${r.path}`) : parent;
if (r.path) paths.push(full);
if (r.children) traverse(r.children as Array<{ path?: string; children?: unknown[] }>, full);
}
}
traverse(router.routes);
return paths;
});
}

test.describe('[CANARY] React Router Route Manifest Exposure', () => {
/**
* Verifies that lazy routes are not pre-populated in router.routes.
* If lazy routes appear in the initial route tree, React Router has changed behavior.
*/
test('React Router should not expose lazy routes before lazy handler resolves', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(500);

const initialRoutes = await extractRoutePaths(page);
const hasSlowFetchInitially = initialRoutes.some(p => p.includes('/slow-fetch/:id'));

// Test passes if routes are not available initially (we need the workaround)
// Test fails if routes are available initially (workaround may not be needed!)
expect(
hasSlowFetchInitially,
`
React Router now exposes lazy routes in the initial route tree!
This means the lazyRouteManifest workaround may no longer be needed.

Initial routes: ${JSON.stringify(initialRoutes, null, 2)}

Next steps:
1. Verify this behavior is consistent and intentional
2. Check React Router changelog for details
3. Consider removing the lazyRouteManifest workaround
`,
).toBe(false);
});

/**
* Verifies that lazy route children are not in router.routes before visiting them.
*/
test('React Router should not have lazy route children before visiting them', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(300);

const routes = await extractRoutePaths(page);
const hasLazyChildren = routes.some(
p =>
p.includes('/lazy/inner/:id') ||
p.includes('/another-lazy/sub/:id') ||
p.includes('/slow-fetch/:id') ||
p.includes('/deep/level2/level3/:id'),
);

// Test passes if lazy children are not in routes before visiting (we need the workaround)
// Test fails if lazy children are in routes before visiting (workaround may not be needed!)
expect(
hasLazyChildren,
`
React Router now includes lazy route children in router.routes upfront!
This means the lazyRouteManifest workaround may no longer be needed.

Routes at home page: ${JSON.stringify(routes, null, 2)}

Next steps:
1. Verify this behavior is consistent and intentional
2. Check React Router changelog for details
3. Consider removing the lazyRouteManifest workaround
`,
).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -1433,3 +1433,54 @@ test('Second navigation span is not corrupted by first slow lazy handler complet
expect(wrongSpans.length).toBe(0);
}
});

// lazyRouteManifest: provides parameterized name when lazy routes don't resolve in time
test('Route manifest provides correct name when navigation span ends before lazy route resolves', async ({ page }) => {
// Short idle timeout (50ms) ensures span ends before lazy route (500ms) resolves
await page.goto('/?idleTimeout=50&timeout=0');

// Wait for pageload to complete
await page.waitForTimeout(200);

const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
return (
!!transactionEvent?.transaction &&
transactionEvent.contexts?.trace?.op === 'navigation' &&
(transactionEvent.transaction?.startsWith('/wildcard-lazy') ?? false)
);
});

// Navigate to wildcard-lazy route (500ms delay in module via top-level await)
const wildcardLazyLink = page.locator('id=navigation-to-wildcard-lazy');
await expect(wildcardLazyLink).toBeVisible();
await wildcardLazyLink.click();

const event = await navigationPromise;

// Should have parameterized name from manifest, not wildcard (/wildcard-lazy/*)
expect(event.transaction).toBe('/wildcard-lazy/:id');
expect(event.type).toBe('transaction');
expect(event.contexts?.trace?.op).toBe('navigation');
expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route');
});

test('Route manifest provides correct name when pageload span ends before lazy route resolves', async ({ page }) => {
// Short idle timeout (50ms) ensures span ends before lazy route (500ms) resolves
const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
return (
!!transactionEvent?.transaction &&
transactionEvent.contexts?.trace?.op === 'pageload' &&
(transactionEvent.transaction?.startsWith('/wildcard-lazy') ?? false)
);
});

await page.goto('/wildcard-lazy/123?idleTimeout=50&timeout=0');

const event = await pageloadPromise;

// Should have parameterized name from manifest, not wildcard (/wildcard-lazy/*)
expect(event.transaction).toBe('/wildcard-lazy/:id');
expect(event.type).toBe('transaction');
expect(event.contexts?.trace?.op).toBe('pageload');
expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route');
});
3 changes: 3 additions & 0 deletions packages/react/src/reactrouter-compat-utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ export {

// Lazy route exports
export { createAsyncHandlerProxy, handleAsyncHandlerResult, checkRouteForAsyncHandler } from './lazy-routes';

// Route manifest exports
export { matchRouteManifest } from './route-manifest';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused export of matchRouteManifest from index.ts

Low Severity

The matchRouteManifest function is exported from index.ts but this export is never used. Both consumers import directly from ./route-manifest instead: utils.ts imports from './route-manifest' and the test file imports from '../../src/reactrouter-compat-utils/route-manifest'. This export is dead code that adds unnecessary noise to the public API surface.

Fix in Cursor Fix in Web

47 changes: 45 additions & 2 deletions packages/react/src/reactrouter-compat-utils/instrumentation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ let _matchRoutes: MatchRoutes;

let _enableAsyncRouteHandlers: boolean = false;
let _lazyRouteTimeout = 3000;
let _lazyRouteManifest: string[] | undefined;
let _basename: string = '';

const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet<Client>();

Expand Down Expand Up @@ -196,6 +198,25 @@ export interface ReactRouterOptions {
* @default idleTimeout * 3
*/
lazyRouteTimeout?: number;

/**
* Static route manifest for resolving parameterized route names with lazy routes.
*
* Requires `enableAsyncRouteHandlers: true`. When provided, the manifest is used
* as the primary source for determining transaction names. This is more reliable
* than depending on React Router's lazy route resolution timing.
*
* @example
* ```ts
* lazyRouteManifest: [
* '/',
* '/users',
* '/users/:userId',
* '/org/:orgSlug/projects/:projectId',
* ]
* ```
*/
lazyRouteManifest?: string[];
}

type V6CompatibleVersion = '6' | '7';
Expand Down Expand Up @@ -355,7 +376,9 @@ export function updateNavigationSpan(
allRoutes,
allRoutes,
(currentBranches as RouteMatch[]) || [],
'',
_basename,
_lazyRouteManifest,
_enableAsyncRouteHandlers,
);

const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
Expand Down Expand Up @@ -520,6 +543,9 @@ export function createV6CompatibleWrapCreateBrowserRouter<
});
}

// Store basename for use in updateNavigationSpan
_basename = basename || '';

setupRouterSubscription(router, routes, version, basename, activeRootSpan);

return router;
Expand Down Expand Up @@ -614,6 +640,9 @@ export function createV6CompatibleWrapCreateMemoryRouter<
});
}

// Store basename for use in updateNavigationSpan
_basename = basename || '';

setupRouterSubscription(router, routes, version, basename, memoryActiveRootSpan);

return router;
Expand All @@ -640,6 +669,7 @@ export function createReactRouterV6CompatibleTracingIntegration(
instrumentPageLoad = true,
instrumentNavigation = true,
lazyRouteTimeout,
lazyRouteManifest,
} = options;

return {
Expand Down Expand Up @@ -683,6 +713,7 @@ export function createReactRouterV6CompatibleTracingIntegration(
_matchRoutes = matchRoutes;
_createRoutesFromChildren = createRoutesFromChildren;
_enableAsyncRouteHandlers = enableAsyncRouteHandlers;
_lazyRouteManifest = lazyRouteManifest;

// Initialize the router utils with the required dependencies
initializeRouterUtils(matchRoutes, stripBasename || false);
Expand Down Expand Up @@ -932,6 +963,8 @@ export function handleNavigation(opts: {
allRoutes || routes,
branches as RouteMatch[],
basename,
_lazyRouteManifest,
_enableAsyncRouteHandlers,
);

const locationKey = computeLocationKey(location);
Expand Down Expand Up @@ -1071,6 +1104,8 @@ function updatePageloadTransaction({
allRoutes || routes,
branches,
basename,
_lazyRouteManifest,
_enableAsyncRouteHandlers,
);

getCurrentScope().setTransactionName(name || '/');
Expand Down Expand Up @@ -1158,7 +1193,15 @@ function tryUpdateSpanNameBeforeEnd(
return;
}

const [name, source] = resolveRouteNameAndSource(location, routesToUse, routesToUse, branches, basename);
const [name, source] = resolveRouteNameAndSource(
location,
routesToUse,
routesToUse,
branches,
basename,
_lazyRouteManifest,
_enableAsyncRouteHandlers,
);

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