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 a35a1b8ae077..7ec92c33f0ce 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
@@ -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,
@@ -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
@@ -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();
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/react-router-manifest.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/react-router-manifest.test.ts
new file mode 100644
index 000000000000..970738b959ef
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/react-router-manifest.test.ts
@@ -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 {
+ return page.evaluate(() => {
+ const router = (window as Record).__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);
+ });
+});
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 9e8b604700a5..16950d3dabdb 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
@@ -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');
+});
diff --git a/packages/react/src/reactrouter-compat-utils/index.ts b/packages/react/src/reactrouter-compat-utils/index.ts
index 968abd9ecae6..beef17a531dd 100644
--- a/packages/react/src/reactrouter-compat-utils/index.ts
+++ b/packages/react/src/reactrouter-compat-utils/index.ts
@@ -35,3 +35,6 @@ export {
// Lazy route exports
export { createAsyncHandlerProxy, handleAsyncHandlerResult, checkRouteForAsyncHandler } from './lazy-routes';
+
+// Route manifest exports
+export { matchRouteManifest } from './route-manifest';
diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx
index 97c91cb04794..5725309ff12b 100644
--- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx
+++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx
@@ -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();
@@ -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';
@@ -355,7 +376,9 @@ export function updateNavigationSpan(
allRoutes,
allRoutes,
(currentBranches as RouteMatch[]) || [],
- '',
+ _basename,
+ _lazyRouteManifest,
+ _enableAsyncRouteHandlers,
);
const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
@@ -520,6 +543,9 @@ export function createV6CompatibleWrapCreateBrowserRouter<
});
}
+ // Store basename for use in updateNavigationSpan
+ _basename = basename || '';
+
setupRouterSubscription(router, routes, version, basename, activeRootSpan);
return router;
@@ -614,6 +640,9 @@ export function createV6CompatibleWrapCreateMemoryRouter<
});
}
+ // Store basename for use in updateNavigationSpan
+ _basename = basename || '';
+
setupRouterSubscription(router, routes, version, basename, memoryActiveRootSpan);
return router;
@@ -640,6 +669,7 @@ export function createReactRouterV6CompatibleTracingIntegration(
instrumentPageLoad = true,
instrumentNavigation = true,
lazyRouteTimeout,
+ lazyRouteManifest,
} = options;
return {
@@ -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);
@@ -932,6 +963,8 @@ export function handleNavigation(opts: {
allRoutes || routes,
branches as RouteMatch[],
basename,
+ _lazyRouteManifest,
+ _enableAsyncRouteHandlers,
);
const locationKey = computeLocationKey(location);
@@ -1071,6 +1104,8 @@ function updatePageloadTransaction({
allRoutes || routes,
branches,
basename,
+ _lazyRouteManifest,
+ _enableAsyncRouteHandlers,
);
getCurrentScope().setTransactionName(name || '/');
@@ -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;
diff --git a/packages/react/src/reactrouter-compat-utils/route-manifest.ts b/packages/react/src/reactrouter-compat-utils/route-manifest.ts
new file mode 100644
index 000000000000..2411f7710f6c
--- /dev/null
+++ b/packages/react/src/reactrouter-compat-utils/route-manifest.ts
@@ -0,0 +1,154 @@
+import { debug } from '@sentry/core';
+import { DEBUG_BUILD } from '../debug-build';
+
+// Cache for sorted manifests - keyed by manifest array reference
+const SORTED_MANIFEST_CACHE = new WeakMap();
+
+/**
+ * Matches a pathname against a route manifest and returns the matching pattern.
+ * Optionally strips a basename prefix before matching.
+ */
+export function matchRouteManifest(pathname: string, manifest: string[], basename?: string): string | null {
+ if (!pathname || !manifest || !manifest.length) {
+ return null;
+ }
+
+ let normalizedPathname = pathname;
+ if (basename && basename !== '/') {
+ const base = basename.endsWith('/') ? basename.slice(0, -1) : basename;
+ if (normalizedPathname.toLowerCase().startsWith(base.toLowerCase())) {
+ // Verify basename ends at segment boundary (followed by / or end of string)
+ const nextChar = normalizedPathname.charAt(base.length);
+ if (nextChar === '/' || nextChar === '') {
+ normalizedPathname = normalizedPathname.slice(base.length) || '/';
+ }
+ }
+ }
+
+ let sorted = SORTED_MANIFEST_CACHE.get(manifest);
+ if (!sorted) {
+ sorted = sortBySpecificity(manifest);
+ SORTED_MANIFEST_CACHE.set(manifest, sorted);
+ DEBUG_BUILD && debug.log('[React Router] Sorted route manifest by specificity:', sorted.length, 'patterns');
+ }
+
+ for (const pattern of sorted) {
+ if (matchesPattern(normalizedPathname, pattern)) {
+ DEBUG_BUILD && debug.log('[React Router] Matched pathname', normalizedPathname, 'to pattern', pattern);
+ return pattern;
+ }
+ }
+
+ DEBUG_BUILD && debug.log('[React Router] No manifest match found for pathname:', normalizedPathname);
+ return null;
+}
+
+/**
+ * Checks if a pathname matches a route pattern.
+ */
+function matchesPattern(pathname: string, pattern: string): boolean {
+ // Handle root path special case
+ if (pattern === '/') {
+ return pathname === '/' || pathname === '';
+ }
+
+ const pathSegments = splitPath(pathname);
+ const patternSegments = splitPath(pattern);
+
+ // Handle wildcard at end
+ const hasWildcard = patternSegments.length > 0 && patternSegments[patternSegments.length - 1] === '*';
+
+ if (hasWildcard) {
+ // Pattern with wildcard: path must have at least as many segments as pattern (minus wildcard)
+ const patternSegmentsWithoutWildcard = patternSegments.length - 1;
+ if (pathSegments.length < patternSegmentsWithoutWildcard) {
+ return false;
+ }
+ for (let i = 0; i < patternSegmentsWithoutWildcard; i++) {
+ if (!segmentMatches(pathSegments[i], patternSegments[i])) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ // Exact segment count match required
+ if (pathSegments.length !== patternSegments.length) {
+ return false;
+ }
+
+ for (let i = 0; i < patternSegments.length; i++) {
+ if (!segmentMatches(pathSegments[i], patternSegments[i])) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Checks if a path segment matches a pattern segment.
+ */
+function segmentMatches(pathSegment: string | undefined, patternSegment: string | undefined): boolean {
+ if (pathSegment === undefined || patternSegment === undefined) {
+ return false;
+ }
+ // Parameter matches anything
+ if (patternSegment.startsWith(':')) {
+ return true;
+ }
+ // Literal must match exactly
+ return pathSegment === patternSegment;
+}
+
+/**
+ * Splits a path into segments, filtering out empty strings.
+ */
+function splitPath(path: string): string[] {
+ return path.split('/').filter(Boolean);
+}
+
+/**
+ * Sorts route patterns by specificity (most specific first).
+ * Mimics React Router's ranking algorithm from computeScore():
+ * https://github.com/remix-run/react-router/blob/main/packages/react-router/lib/router/utils.ts
+ *
+ * React Router scoring: static=10, dynamic=3, splat=-2 penalty, index=+2 bonus
+ * Our simplified approach produces equivalent ordering:
+ * - Non-wildcard patterns are more specific than wildcard patterns
+ * - More segments = more specific
+ * - Among same-length patterns, more literal segments = more specific
+ * - Equal specificity: preserves manifest order (same as React Router)
+ *
+ * Note: Users should order their manifest from most specific to least specific
+ * when patterns have equal specificity (e.g., `/users/:id/settings` and `/:type/123/settings`).
+ */
+function sortBySpecificity(manifest: string[]): string[] {
+ return [...manifest].sort((a, b) => {
+ const aSegments = splitPath(a);
+ const bSegments = splitPath(b);
+ const aHasWildcard = aSegments.length > 0 && aSegments[aSegments.length - 1] === '*';
+ const bHasWildcard = bSegments.length > 0 && bSegments[bSegments.length - 1] === '*';
+
+ // Non-wildcard patterns are more specific than wildcard patterns
+ if (aHasWildcard !== bHasWildcard) {
+ return aHasWildcard ? 1 : -1;
+ }
+
+ // For comparison, exclude wildcard from segment count
+ const aLen = aHasWildcard ? aSegments.length - 1 : aSegments.length;
+ const bLen = bHasWildcard ? bSegments.length - 1 : bSegments.length;
+
+ // More segments = more specific
+ if (aLen !== bLen) {
+ return bLen - aLen;
+ }
+
+ // Same length: count literal segments (non-params, non-wildcards)
+ const aLiterals = aSegments.filter(s => !s.startsWith(':') && s !== '*').length;
+ const bLiterals = bSegments.filter(s => !s.startsWith(':') && s !== '*').length;
+
+ // More literals = more specific (equal specificity preserves original order)
+ return bLiterals - aLiterals;
+ });
+}
diff --git a/packages/react/src/reactrouter-compat-utils/utils.ts b/packages/react/src/reactrouter-compat-utils/utils.ts
index 96c178b64c14..70fb5d829354 100644
--- a/packages/react/src/reactrouter-compat-utils/utils.ts
+++ b/packages/react/src/reactrouter-compat-utils/utils.ts
@@ -2,6 +2,7 @@ import type { Span, TransactionSource } from '@sentry/core';
import { debug, getActiveSpan, getRootSpan, spanToJSON } from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
import type { Location, MatchRoutes, RouteMatch, RouteObject } from '../types';
+import { matchRouteManifest } from './route-manifest';
// Global variables that these utilities depend on
let _matchRoutes: MatchRoutes;
@@ -303,7 +304,18 @@ export function resolveRouteNameAndSource(
allRoutes: RouteObject[],
branches: RouteMatch[],
basename: string = '',
+ lazyRouteManifest?: string[],
+ enableAsyncRouteHandlers?: boolean,
): [string, TransactionSource] {
+ // When lazy route manifest is provided, use it as the primary source for transaction names
+ if (enableAsyncRouteHandlers && lazyRouteManifest && lazyRouteManifest.length > 0) {
+ const manifestMatch = matchRouteManifest(location.pathname, lazyRouteManifest, basename);
+ if (manifestMatch) {
+ return [manifestMatch, 'route'];
+ }
+ }
+
+ // Fall back to React Router route matching
let name: string | undefined;
let source: TransactionSource = 'url';
diff --git a/packages/react/test/reactrouter-compat-utils/route-manifest.test.ts b/packages/react/test/reactrouter-compat-utils/route-manifest.test.ts
new file mode 100644
index 000000000000..750251d5ec17
--- /dev/null
+++ b/packages/react/test/reactrouter-compat-utils/route-manifest.test.ts
@@ -0,0 +1,363 @@
+import { describe, expect, it } from 'vitest';
+import { matchRouteManifest } from '../../src/reactrouter-compat-utils/route-manifest';
+
+describe('matchRouteManifest', () => {
+ const manifest = [
+ '/',
+ '/pricing',
+ '/features',
+ '/login',
+ '/signup',
+ '/reset-password/:token',
+ '/org/:orgSlug',
+ '/org/:orgSlug/dashboard',
+ '/org/:orgSlug/projects',
+ '/org/:orgSlug/projects/:projectId',
+ '/org/:orgSlug/projects/:projectId/settings',
+ '/org/:orgSlug/projects/:projectId/issues',
+ '/org/:orgSlug/projects/:projectId/issues/:issueId',
+ '/admin',
+ '/admin/users',
+ '/admin/users/:userId',
+ '/wildcard/*',
+ ];
+
+ describe('exact matches', () => {
+ it('matches root path', () => {
+ expect(matchRouteManifest('/', manifest)).toBe('/');
+ });
+
+ it('matches simple paths', () => {
+ expect(matchRouteManifest('/pricing', manifest)).toBe('/pricing');
+ expect(matchRouteManifest('/features', manifest)).toBe('/features');
+ expect(matchRouteManifest('/login', manifest)).toBe('/login');
+ });
+
+ it('matches admin paths', () => {
+ expect(matchRouteManifest('/admin', manifest)).toBe('/admin');
+ expect(matchRouteManifest('/admin/users', manifest)).toBe('/admin/users');
+ });
+ });
+
+ describe('parameterized routes', () => {
+ it('matches single parameter', () => {
+ expect(matchRouteManifest('/reset-password/abc123', manifest)).toBe('/reset-password/:token');
+ expect(matchRouteManifest('/admin/users/42', manifest)).toBe('/admin/users/:userId');
+ });
+
+ it('matches multiple parameters', () => {
+ expect(matchRouteManifest('/org/acme', manifest)).toBe('/org/:orgSlug');
+ expect(matchRouteManifest('/org/acme/dashboard', manifest)).toBe('/org/:orgSlug/dashboard');
+ expect(matchRouteManifest('/org/acme/projects', manifest)).toBe('/org/:orgSlug/projects');
+ expect(matchRouteManifest('/org/acme/projects/123', manifest)).toBe('/org/:orgSlug/projects/:projectId');
+ expect(matchRouteManifest('/org/acme/projects/123/settings', manifest)).toBe(
+ '/org/:orgSlug/projects/:projectId/settings',
+ );
+ expect(matchRouteManifest('/org/acme/projects/123/issues', manifest)).toBe(
+ '/org/:orgSlug/projects/:projectId/issues',
+ );
+ expect(matchRouteManifest('/org/acme/projects/123/issues/456', manifest)).toBe(
+ '/org/:orgSlug/projects/:projectId/issues/:issueId',
+ );
+ });
+ });
+
+ describe('wildcard routes', () => {
+ it('matches wildcard with extra segments', () => {
+ expect(matchRouteManifest('/wildcard/anything', manifest)).toBe('/wildcard/*');
+ expect(matchRouteManifest('/wildcard/foo/bar/baz', manifest)).toBe('/wildcard/*');
+ });
+
+ it('matches wildcard with no extra segments (matches React Router behavior)', () => {
+ // React Router's matchPath('/wildcard/*', '/wildcard') returns { params: { '*': '' } }
+ // The splat can be empty string, so /wildcard matches /wildcard/*
+ expect(matchRouteManifest('/wildcard', manifest)).toBe('/wildcard/*');
+ });
+ });
+
+ describe('specificity sorting (React Router parity)', () => {
+ // Verifies our sorting matches React Router's computeScore() algorithm
+ // See: https://github.com/remix-run/react-router/blob/main/packages/react-router/lib/router/utils.ts
+ // React Router scoring: static=10, dynamic=3, splat=-2 penalty, index=+2 bonus
+ // For equal scores, manifest order is preserved (same as React Router)
+
+ it('returns more specific route when multiple match', () => {
+ // /org/:orgSlug should not match /org/acme/projects - the more specific pattern should win
+ expect(matchRouteManifest('/org/acme/projects', manifest)).toBe('/org/:orgSlug/projects');
+ });
+
+ it('prefers literal segments over parameters (React Router: static=10 > dynamic=3)', () => {
+ const manifestWithOverlap = ['/users/:id', '/users/me'];
+ // /users/me: 2 + 10 + 10 = 22
+ // /users/:id: 2 + 10 + 3 = 15
+ expect(matchRouteManifest('/users/me', manifestWithOverlap)).toBe('/users/me');
+ expect(matchRouteManifest('/users/123', manifestWithOverlap)).toBe('/users/:id');
+ });
+
+ it('prefers more segments (React Router: higher segment count = higher base score)', () => {
+ const m = ['/users', '/users/:id', '/users/:id/posts'];
+ // /users: 1 + 10 = 11
+ // /users/:id: 2 + 10 + 3 = 15
+ // /users/:id/posts: 3 + 10 + 3 + 10 = 26
+ expect(matchRouteManifest('/users/123/posts', m)).toBe('/users/:id/posts');
+ });
+
+ it('prefers non-wildcard over wildcard (React Router: splat=-2 penalty)', () => {
+ const m = ['/docs/*', '/docs/api'];
+ // /docs/*: 2 + 10 + (-2) = 10
+ // /docs/api: 2 + 10 + 10 = 22
+ expect(matchRouteManifest('/docs/api', m)).toBe('/docs/api');
+ });
+
+ it('prefers longer wildcard prefix over shorter (React Router: more segments before splat)', () => {
+ const m = ['/*', '/docs/*', '/docs/api/*'];
+ // /*: 1 + (-2) = -1
+ // /docs/*: 2 + 10 + (-2) = 10
+ // /docs/api/*: 3 + 10 + 10 + (-2) = 21
+ expect(matchRouteManifest('/docs/api/methods', m)).toBe('/docs/api/*');
+ expect(matchRouteManifest('/docs/guide', m)).toBe('/docs/*');
+ expect(matchRouteManifest('/other', m)).toBe('/*');
+ });
+ });
+
+ describe('no match', () => {
+ it('returns null for unmatched paths', () => {
+ expect(matchRouteManifest('/unknown', manifest)).toBe(null);
+ expect(matchRouteManifest('/org/acme/unknown', manifest)).toBe(null);
+ expect(matchRouteManifest('/admin/unknown/path', manifest)).toBe(null);
+ });
+
+ it('returns null for empty manifest', () => {
+ expect(matchRouteManifest('/pricing', [])).toBe(null);
+ });
+ });
+
+ describe('edge cases', () => {
+ it('returns null for empty pathname', () => {
+ expect(matchRouteManifest('', manifest)).toBe(null);
+ });
+
+ it('returns null for undefined-like inputs', () => {
+ expect(matchRouteManifest(null as unknown as string, manifest)).toBe(null);
+ expect(matchRouteManifest(undefined as unknown as string, manifest)).toBe(null);
+ expect(matchRouteManifest('/test', null as unknown as string[])).toBe(null);
+ expect(matchRouteManifest('/test', undefined as unknown as string[])).toBe(null);
+ });
+
+ it('handles trailing slashes by normalizing them', () => {
+ expect(matchRouteManifest('/pricing/', manifest)).toBe('/pricing');
+ expect(matchRouteManifest('/admin/users/', manifest)).toBe('/admin/users');
+ });
+
+ it('handles pathname without leading slash by normalizing', () => {
+ expect(matchRouteManifest('pricing', manifest)).toBe('/pricing');
+ });
+
+ it('handles double slashes in pathname', () => {
+ expect(matchRouteManifest('//pricing', manifest)).toBe('/pricing');
+ expect(matchRouteManifest('///admin//users', manifest)).toBe('/admin/users');
+ });
+
+ it('handles encoded characters in pathname', () => {
+ expect(matchRouteManifest('/admin/users/John%20Doe', manifest)).toBe('/admin/users/:userId');
+ expect(matchRouteManifest('/org/my%2Forg/dashboard', manifest)).toBe('/org/:orgSlug/dashboard');
+ });
+
+ it('handles different manifests with same length correctly', () => {
+ const manifest1 = ['/users/:id', '/posts/:id'];
+ const manifest2 = ['/orders/:id', '/items/:id'];
+
+ expect(matchRouteManifest('/users/123', manifest1)).toBe('/users/:id');
+ expect(matchRouteManifest('/orders/456', manifest2)).toBe('/orders/:id');
+ expect(matchRouteManifest('/posts/789', manifest1)).toBe('/posts/:id');
+ expect(matchRouteManifest('/items/abc', manifest2)).toBe('/items/:id');
+ });
+ });
+
+ describe('advanced specificity', () => {
+ it('handles consecutive parameters', () => {
+ const m = ['/:a/:b', '/:a/literal'];
+ expect(matchRouteManifest('/foo/literal', m)).toBe('/:a/literal');
+ expect(matchRouteManifest('/foo/other', m)).toBe('/:a/:b');
+ });
+
+ it('matches wildcard after parameter', () => {
+ const m = ['/users/:id', '/users/:id/*'];
+ expect(matchRouteManifest('/users/123', m)).toBe('/users/:id');
+ expect(matchRouteManifest('/users/123/settings/advanced', m)).toBe('/users/:id/*');
+ });
+
+ it('matches root wildcard as fallback', () => {
+ const m = ['/', '/pricing', '/*'];
+ expect(matchRouteManifest('/unknown/path', m)).toBe('/*');
+ expect(matchRouteManifest('/pricing', m)).toBe('/pricing');
+ });
+
+ it('returns same result regardless of manifest order', () => {
+ const m1 = ['/users/:id', '/users/me', '/users'];
+ const m2 = ['/users/me', '/users', '/users/:id'];
+ const m3 = ['/users', '/users/:id', '/users/me'];
+
+ expect(matchRouteManifest('/users/me', m1)).toBe('/users/me');
+ expect(matchRouteManifest('/users/me', m2)).toBe('/users/me');
+ expect(matchRouteManifest('/users/me', m3)).toBe('/users/me');
+ });
+
+ it('handles dots in pathname segments', () => {
+ const m = ['/api/v1.0/users', '/api/:version/users'];
+ expect(matchRouteManifest('/api/v1.0/users', m)).toBe('/api/v1.0/users');
+ expect(matchRouteManifest('/api/v2.0/users', m)).toBe('/api/:version/users');
+ });
+
+ it('handles deeply nested paths correctly', () => {
+ const m = ['/a/b/c/d/e/:f', '/a/b/c/d/:e/:f', '/a/b/c/:d/:e/:f'];
+ expect(matchRouteManifest('/a/b/c/d/e/123', m)).toBe('/a/b/c/d/e/:f');
+ expect(matchRouteManifest('/a/b/c/d/x/123', m)).toBe('/a/b/c/d/:e/:f');
+ expect(matchRouteManifest('/a/b/c/x/y/123', m)).toBe('/a/b/c/:d/:e/:f');
+ });
+
+ it('preserves manifest order when literal count is equal (React Router parity)', () => {
+ // Both have 2 literals with equal specificity scores
+ // React Router uses definition order for equal scores, so first in manifest wins
+ // Users should order their manifest from most specific to least specific
+ const m1 = ['/users/:id/settings', '/:type/123/settings'];
+ expect(matchRouteManifest('/users/123/settings', m1)).toBe('/users/:id/settings');
+
+ const m2 = ['/:type/123/settings', '/users/:id/settings'];
+ expect(matchRouteManifest('/users/123/settings', m2)).toBe('/:type/123/settings');
+ });
+ });
+
+ describe('manifest pattern normalization', () => {
+ it('handles manifest patterns without leading slash', () => {
+ const m = ['users/:id', 'posts'];
+ expect(matchRouteManifest('/users/123', m)).toBe('users/:id');
+ expect(matchRouteManifest('/posts', m)).toBe('posts');
+ });
+
+ it('handles manifest patterns with trailing slashes', () => {
+ const m = ['/users/', '/posts/:id/'];
+ expect(matchRouteManifest('/users', m)).toBe('/users/');
+ expect(matchRouteManifest('/posts/123', m)).toBe('/posts/:id/');
+ });
+
+ it('handles duplicate patterns in manifest', () => {
+ const m = ['/users/:id', '/users/:id', '/posts'];
+ expect(matchRouteManifest('/users/123', m)).toBe('/users/:id');
+ expect(matchRouteManifest('/posts', m)).toBe('/posts');
+ });
+ });
+
+ describe('wildcard edge cases', () => {
+ it('prefers literal over wildcard at same depth', () => {
+ const m = ['/users/settings', '/users/*'];
+ expect(matchRouteManifest('/users/settings', m)).toBe('/users/settings');
+ expect(matchRouteManifest('/users/profile', m)).toBe('/users/*');
+ });
+
+ it('handles wildcard with parameter prefix', () => {
+ const m = ['/:locale/*', '/:locale/about'];
+ expect(matchRouteManifest('/en/about', m)).toBe('/:locale/about');
+ expect(matchRouteManifest('/en/anything/else', m)).toBe('/:locale/*');
+ });
+
+ it('handles root wildcard matching everything', () => {
+ const m = ['/*'];
+ expect(matchRouteManifest('/anything', m)).toBe('/*');
+ expect(matchRouteManifest('/deep/nested/path', m)).toBe('/*');
+ expect(matchRouteManifest('/', m)).toBe('/*');
+ });
+ });
+
+ describe('parameter patterns', () => {
+ it('handles patterns starting with parameter', () => {
+ const m = ['/:locale/users', '/:locale/posts/:id'];
+ expect(matchRouteManifest('/en/users', m)).toBe('/:locale/users');
+ expect(matchRouteManifest('/fr/posts/123', m)).toBe('/:locale/posts/:id');
+ });
+
+ it('handles single-segment parameter pattern', () => {
+ const m = ['/:id'];
+ expect(matchRouteManifest('/123', m)).toBe('/:id');
+ expect(matchRouteManifest('/abc', m)).toBe('/:id');
+ });
+
+ it('matches numeric-only pathname segments', () => {
+ const m = ['/:a/:b', '/users/:id'];
+ expect(matchRouteManifest('/123/456', m)).toBe('/:a/:b');
+ expect(matchRouteManifest('/users/789', m)).toBe('/users/:id');
+ });
+
+ it('handles special characters in literal segments', () => {
+ const m = ['/api-v1/users', '/api_v2/posts', '/api.v3/items'];
+ expect(matchRouteManifest('/api-v1/users', m)).toBe('/api-v1/users');
+ expect(matchRouteManifest('/api_v2/posts', m)).toBe('/api_v2/posts');
+ expect(matchRouteManifest('/api.v3/items', m)).toBe('/api.v3/items');
+ });
+ });
+
+ describe('basename handling', () => {
+ it('strips basename before matching', () => {
+ expect(matchRouteManifest('/app/pricing', manifest, '/app')).toBe('/pricing');
+ expect(matchRouteManifest('/app/org/acme/projects', manifest, '/app')).toBe('/org/:orgSlug/projects');
+ });
+
+ it('handles basename with trailing slash', () => {
+ expect(matchRouteManifest('/app/pricing', manifest, '/app/')).toBe('/pricing');
+ expect(matchRouteManifest('/app/login', manifest, '/app/')).toBe('/login');
+ });
+
+ it('handles root basename', () => {
+ expect(matchRouteManifest('/pricing', manifest, '/')).toBe('/pricing');
+ });
+
+ it('handles case-insensitive basename matching', () => {
+ expect(matchRouteManifest('/APP/pricing', manifest, '/app')).toBe('/pricing');
+ expect(matchRouteManifest('/App/features', manifest, '/APP')).toBe('/features');
+ });
+
+ it('returns root when pathname equals basename', () => {
+ expect(matchRouteManifest('/app', manifest, '/app')).toBe('/');
+ });
+
+ it('does not strip basename that is not a prefix', () => {
+ expect(matchRouteManifest('/other/pricing', manifest, '/app')).toBe(null);
+ });
+
+ it('handles undefined basename', () => {
+ expect(matchRouteManifest('/pricing', manifest, undefined)).toBe('/pricing');
+ });
+
+ it('handles empty string basename', () => {
+ expect(matchRouteManifest('/pricing', manifest, '')).toBe('/pricing');
+ });
+
+ it('handles multi-level basename', () => {
+ expect(matchRouteManifest('/app/v1/pricing', manifest, '/app/v1')).toBe('/pricing');
+ expect(matchRouteManifest('/app/v1/org/acme', manifest, '/app/v1')).toBe('/org/:orgSlug');
+ });
+
+ it('handles basename with special characters', () => {
+ expect(matchRouteManifest('/my-app/pricing', manifest, '/my-app')).toBe('/pricing');
+ expect(matchRouteManifest('/my_app/login', manifest, '/my_app')).toBe('/login');
+ });
+
+ it('handles basename longer than pathname', () => {
+ expect(matchRouteManifest('/app', manifest, '/app/v1/admin')).toBe(null);
+ });
+
+ it('does not strip basename that is a partial word match', () => {
+ // /app should NOT match /application - basename must be complete segment
+ expect(matchRouteManifest('/application/pricing', manifest, '/app')).toBe(null);
+ expect(matchRouteManifest('/apps/pricing', manifest, '/app')).toBe(null);
+ });
+
+ it('does not incorrectly match when partial basename strip creates valid path', () => {
+ // Bug scenario: basename /a, pathname /ab/pricing
+ // Wrong: strips /a, leaving b/pricing which could match /b/pricing
+ // Correct: /a is not a complete segment of /ab, so no stripping should occur
+ const m = ['/b/pricing', '/pricing'];
+ expect(matchRouteManifest('/ab/pricing', m, '/a')).toBe(null);
+ });
+ });
+});