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); + }); + }); +});