diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 57a07f1b..e350d78d 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -37,6 +37,10 @@ const requestPipelinePath = fileURLToPath( const requestContextShimPath = fileURLToPath( new URL("../shims/request-context.js", import.meta.url), ).replace(/\\/g, "/"); +const routeTriePath = fileURLToPath(new URL("../routing/route-trie.js", import.meta.url)).replace( + /\\/g, + "/", +); /** * Resolved config options relevant to App Router request handling. @@ -260,6 +264,7 @@ import { validateCsrfOrigin, validateImageUrl, guardProtocolRelativeUrl, hasBase import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from ${JSON.stringify(requestContextShimPath)}; import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache"; +import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from ${JSON.stringify(routeTriePath)}; import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; // Import server-only state module to register ALS-backed accessors. import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state"; @@ -599,6 +604,7 @@ async function __ensureInstrumentation() { const routes = [ ${routeEntries.join(",\n")} ]; +const _routeTrie = _buildRouteTrie(routes); const metadataRoutes = [ ${metaRouteEntries.join(",\n")} @@ -897,20 +903,17 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc }); } -function matchRoute(url, routes) { +function matchRoute(url) { const pathname = url.split("?")[0]; let normalizedUrl = pathname === "/" ? "/" : pathname.replace(/\\/$/, ""); // NOTE: Do NOT decodeURIComponent here. The caller is responsible for decoding // the pathname exactly once at the request entry point. Decoding again here // would cause inconsistent path matching between middleware and routing. const urlParts = normalizedUrl.split("/").filter(Boolean); - for (const route of routes) { - const params = matchPattern(urlParts, route.patternParts); - if (params !== null) return { route, params }; - } - return null; + return _trieMatch(_routeTrie, urlParts); } +// matchPattern is kept for findIntercept (linear scan over small interceptLookup array). function matchPattern(urlParts, patternParts) { const params = Object.create(null); for (let i = 0; i < patternParts.length; i++) { @@ -1819,7 +1822,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // After the action, re-render the current page so the client // gets an updated React tree reflecting any mutations. - const match = matchRoute(cleanPathname, routes); + const match = matchRoute(cleanPathname); let element; if (match) { const { route: actionRoute, params: actionParams } = match; @@ -1894,7 +1897,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } - let match = matchRoute(cleanPathname, routes); + let match = matchRoute(cleanPathname); // ── Fallback rewrites from next.config.js (if no route matched) ─────── if (!match && __configRewrites.fallback && __configRewrites.fallback.length) { @@ -1906,7 +1909,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return proxyExternalRequest(request, __fallbackRewritten); } cleanPathname = __fallbackRewritten; - match = matchRoute(cleanPathname, routes); + match = matchRoute(cleanPathname); } } @@ -2342,7 +2345,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const sourceRoute = routes[intercept.sourceRouteIndex]; if (sourceRoute && sourceRoute !== route) { // Render the source route (e.g. /feed) with the intercepting page in the slot - const sourceMatch = matchRoute(sourceRoute.pattern, routes); + const sourceMatch = matchRoute(sourceRoute.pattern); const sourceParams = sourceMatch ? sourceMatch.params : {}; setNavigationContext({ pathname: cleanPathname, diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index bb320985..1c1cf408 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -24,6 +24,10 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const _requestContextShimPath = fileURLToPath( new URL("../shims/request-context.js", import.meta.url), ).replace(/\\/g, "/"); +const _routeTriePath = fileURLToPath(new URL("../routing/route-trie.js", import.meta.url)).replace( + /\\/g, + "/", +); /** * Generate the virtual SSR server entry module. @@ -264,6 +268,7 @@ import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontSty import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; import { parseCookies } from ${JSON.stringify(path.resolve(__dirname, "../config/config-matchers.js").replace(/\\/g, "/"))}; import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from ${JSON.stringify(_requestContextShimPath)}; +import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from ${JSON.stringify(_routeTriePath)}; ${instrumentationImportCode} ${middlewareImportCode} @@ -358,10 +363,12 @@ ${docImportCode} const pageRoutes = [ ${pageRouteEntries.join(",\n")} ]; +const _pageRouteTrie = _buildRouteTrie(pageRoutes); const apiRoutes = [ ${apiRouteEntries.join(",\n")} ]; +const _apiRouteTrie = _buildRouteTrie(apiRoutes); function matchRoute(url, routes) { const pathname = url.split("?")[0]; @@ -369,40 +376,8 @@ function matchRoute(url, routes) { // NOTE: Do NOT decodeURIComponent here. The pathname is already decoded at // the entry point. Decoding again would create a double-decode vector. const urlParts = normalizedUrl.split("/").filter(Boolean); - for (const route of routes) { - const params = matchPattern(urlParts, route.patternParts); - if (params !== null) return { route, params }; - } - return null; -} - -function matchPattern(urlParts, patternParts) { - const params = Object.create(null); - for (let i = 0; i < patternParts.length; i++) { - const pp = patternParts[i]; - if (pp.endsWith("+")) { - if (i !== patternParts.length - 1) return null; - const paramName = pp.slice(1, -1); - const remaining = urlParts.slice(i); - if (remaining.length === 0) return null; - params[paramName] = remaining; - return params; - } - if (pp.endsWith("*")) { - if (i !== patternParts.length - 1) return null; - const paramName = pp.slice(1, -1); - params[paramName] = urlParts.slice(i); - return params; - } - if (pp.startsWith(":")) { - if (i >= urlParts.length) return null; - params[pp.slice(1)] = urlParts[i]; - continue; - } - if (i >= urlParts.length || urlParts[i] !== pp) return null; - } - if (urlParts.length !== patternParts.length) return null; - return params; + const trie = routes === pageRoutes ? _pageRouteTrie : _apiRouteTrie; + return _trieMatch(trie, urlParts); } function parseQuery(url) { diff --git a/packages/vinext/src/routing/app-router.ts b/packages/vinext/src/routing/app-router.ts index 78914b70..e6f5b2a8 100644 --- a/packages/vinext/src/routing/app-router.ts +++ b/packages/vinext/src/routing/app-router.ts @@ -22,6 +22,7 @@ import { type ValidFileMatcher, } from "./file-matcher.js"; import { validateRoutePatterns } from "./route-validation.js"; +import { buildRouteTrie, trieMatch, type TrieNode } from "./route-trie.js"; export interface InterceptingRoute { /** The interception convention: "." | ".." | "../.." | "..." */ @@ -1008,6 +1009,18 @@ function hasRemainingVisibleSegments(segments: string[], startIndex: number): bo return false; } +// Trie cache — keyed by route array identity (same array = same trie) +const appTrieCache = new WeakMap>(); + +function getOrBuildAppTrie(routes: AppRoute[]): TrieNode { + let trie = appTrieCache.get(routes); + if (!trie) { + trie = buildRouteTrie(routes); + appTrieCache.set(routes, trie); + } + return trie; +} + function joinRoutePattern(basePattern: string, subPath: string): string { if (!subPath) return basePattern; return basePattern === "/" ? `/${subPath}` : `${basePattern}/${subPath}`; @@ -1028,56 +1041,8 @@ export function matchAppRoute( /* malformed percent-encoding — match as-is */ } - // Split URL once, reuse across all route match attempts + // Split URL once, look up via trie const urlParts = normalizedUrl.split("/").filter(Boolean); - - for (const route of routes) { - const params = matchPattern(urlParts, route.patternParts); - if (params !== null) { - return { route, params }; - } - } - - return null; -} - -function matchPattern( - urlParts: string[], - patternParts: string[], -): Record | null { - const params: Record = Object.create(null); - - for (let i = 0; i < patternParts.length; i++) { - const pp = patternParts[i]; - - if (pp.endsWith("+")) { - if (i !== patternParts.length - 1) return null; - const paramName = pp.slice(1, -1); - const remaining = urlParts.slice(i); - if (remaining.length === 0) return null; - params[paramName] = remaining; - return params; - } - - if (pp.endsWith("*")) { - if (i !== patternParts.length - 1) return null; - const paramName = pp.slice(1, -1); - const remaining = urlParts.slice(i); - params[paramName] = remaining; - return params; - } - - if (pp.startsWith(":")) { - const paramName = pp.slice(1); - if (i >= urlParts.length) return null; - params[paramName] = urlParts[i]; - continue; - } - - if (i >= urlParts.length || urlParts[i] !== pp) return null; - } - - if (urlParts.length !== patternParts.length) return null; - - return params; + const trie = getOrBuildAppTrie(routes); + return trieMatch(trie, urlParts); } diff --git a/packages/vinext/src/routing/pages-router.ts b/packages/vinext/src/routing/pages-router.ts index f09c7714..51395eb6 100644 --- a/packages/vinext/src/routing/pages-router.ts +++ b/packages/vinext/src/routing/pages-router.ts @@ -6,6 +6,7 @@ import { type ValidFileMatcher, } from "./file-matcher.js"; import { patternToNextFormat, validateRoutePatterns } from "./route-validation.js"; +import { buildRouteTrie, trieMatch, type TrieNode } from "./route-trie.js"; export interface Route { /** URL pattern, e.g. "/" or "/about" or "/posts/:id" */ @@ -155,6 +156,18 @@ function fileToRoute(file: string, pagesDir: string, matcher: ValidFileMatcher): }; } +// Trie cache — keyed by route array identity (same array = same trie) +const trieCache = new WeakMap>(); + +function getOrBuildTrie(routes: Route[]): TrieNode { + let trie = trieCache.get(routes); + if (!trie) { + trie = buildRouteTrie(routes); + trieCache.set(routes, trie); + } + return trie; +} + /** * Match a URL path against a route pattern. * Returns the matched params or null if no match. @@ -172,17 +185,10 @@ export function matchRoute( /* malformed percent-encoding — match as-is */ } - // Split URL once, reuse across all route match attempts + // Split URL once, look up via trie const urlParts = normalizedUrl.split("/").filter(Boolean); - - for (const route of routes) { - const params = matchPattern(urlParts, route.patternParts); - if (params !== null) { - return { route, params }; - } - } - - return null; + const trie = getOrBuildTrie(routes); + return trieMatch(trie, urlParts); } /** @@ -240,52 +246,6 @@ async function scanApiRoutes(pagesDir: string, matcher: ValidFileMatcher): Promi return routes; } -function matchPattern( - urlParts: string[], - patternParts: string[], -): Record | null { - const params: Record = Object.create(null); - - for (let i = 0; i < patternParts.length; i++) { - const pp = patternParts[i]; - - // Catch-all: :slug+ - if (pp.endsWith("+")) { - if (i !== patternParts.length - 1) return null; - const paramName = pp.slice(1, -1); - const remaining = urlParts.slice(i); - if (remaining.length === 0) return null; - params[paramName] = remaining; - return params; - } - - // Optional catch-all: :slug* - if (pp.endsWith("*")) { - if (i !== patternParts.length - 1) return null; - const paramName = pp.slice(1, -1); - const remaining = urlParts.slice(i); - params[paramName] = remaining; - return params; - } - - // Dynamic segment: :id - if (pp.startsWith(":")) { - const paramName = pp.slice(1); - if (i >= urlParts.length) return null; - params[paramName] = urlParts[i]; - continue; - } - - // Static segment - if (i >= urlParts.length || urlParts[i] !== pp) return null; - } - - // All pattern parts matched - check url doesn't have extra segments - if (urlParts.length !== patternParts.length) return null; - - return params; -} - /** * Convert internal route pattern (e.g., "/posts/:id", "/docs/:slug+") * to Next.js bracket format (e.g., "/posts/[id]", "/docs/[...slug]"). diff --git a/packages/vinext/src/routing/route-trie.ts b/packages/vinext/src/routing/route-trie.ts new file mode 100644 index 00000000..9ef3b0ea --- /dev/null +++ b/packages/vinext/src/routing/route-trie.ts @@ -0,0 +1,195 @@ +/** + * Trie (prefix tree) for O(depth) route matching. + * + * Replaces the O(n) linear scan over pre-sorted routes with a trie-based + * lookup. Priority is enforced by traversal order at each node: + * 1. Static child (exact segment match) — highest priority + * 2. Dynamic child (single-segment param) — medium + * 3. Catch-all (1+ remaining segments) — low + * 4. Optional catch-all (0+ remaining segments) — lowest + * + * Backtracking via recursive DFS ensures that dead-end static/dynamic + * branches fall through to catch-all alternatives. + */ + +export interface TrieNode { + staticChildren: Map>; + dynamicChild: { paramName: string; node: TrieNode } | null; + catchAllChild: { paramName: string; route: R } | null; + optionalCatchAllChild: { paramName: string; route: R } | null; + route: R | null; +} + +function createNode(): TrieNode { + return { + staticChildren: new Map(), + dynamicChild: null, + catchAllChild: null, + optionalCatchAllChild: null, + route: null, + }; +} + +/** + * Build a trie from pre-sorted routes. + * + * Routes must have a `patternParts` property (string[] of URL segments). + * Pattern segment conventions: + * - `:name` — dynamic segment + * - `:name+` — catch-all (1+ segments) + * - `:name*` — optional catch-all (0+ segments) + * - anything else — static segment + * + * First route to claim a terminal position wins (routes are pre-sorted + * by precedence, so insertion order preserves correct priority). + */ +export function buildRouteTrie(routes: R[]): TrieNode { + const root = createNode(); + + for (const route of routes) { + const parts = route.patternParts; + + // Root route (patternParts = []) + if (parts.length === 0) { + if (root.route === null) { + root.route = route; + } + continue; + } + + let node = root; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + + // Catch-all: :name+ (must be terminal — skip malformed non-terminal catch-alls) + if (part.endsWith("+") && part.startsWith(":")) { + if (i !== parts.length - 1) break; // malformed: not terminal + const paramName = part.slice(1, -1); + if (node.catchAllChild === null) { + node.catchAllChild = { paramName, route }; + } + break; + } + + // Optional catch-all: :name* (must be terminal — skip malformed non-terminal) + if (part.endsWith("*") && part.startsWith(":")) { + if (i !== parts.length - 1) break; // malformed: not terminal + const paramName = part.slice(1, -1); + if (node.optionalCatchAllChild === null) { + node.optionalCatchAllChild = { paramName, route }; + } + break; + } + + // Dynamic segment: :name + if (part.startsWith(":")) { + const paramName = part.slice(1); + if (node.dynamicChild === null) { + node.dynamicChild = { paramName, node: createNode() }; + } + node = node.dynamicChild.node; + + // If this is the last segment, set the route + if (i === parts.length - 1) { + if (node.route === null) { + node.route = route; + } + } + continue; + } + + // Static segment + let child = node.staticChildren.get(part); + if (!child) { + child = createNode(); + node.staticChildren.set(part, child); + } + node = child; + + // If this is the last segment, set the route + if (i === parts.length - 1) { + if (node.route === null) { + node.route = route; + } + } + } + } + + return root; +} + +/** + * Match a URL against the trie. + * + * @param root - Trie root built by `buildRouteTrie` + * @param urlParts - Pre-split URL segments (no empty strings) + * @returns Match result with route and extracted params, or null + */ +export function trieMatch( + root: TrieNode, + urlParts: string[], +): { route: R; params: Record } | null { + return match(root, urlParts, 0); +} + +function match( + node: TrieNode, + urlParts: string[], + index: number, +): { route: R; params: Record } | null { + // All URL segments consumed + if (index === urlParts.length) { + // Exact match at this node + if (node.route !== null) { + return { route: node.route, params: Object.create(null) }; + } + + // Optional catch-all with 0 segments + if (node.optionalCatchAllChild !== null) { + const params: Record = Object.create(null); + params[node.optionalCatchAllChild.paramName] = []; + return { route: node.optionalCatchAllChild.route, params }; + } + + return null; + } + + const segment = urlParts[index]; + + // 1. Try static child (highest priority) + const staticChild = node.staticChildren.get(segment); + if (staticChild) { + const result = match(staticChild, urlParts, index + 1); + if (result !== null) { + return result; + } + } + + // 2. Try dynamic child (single segment) + if (node.dynamicChild !== null) { + const result = match(node.dynamicChild.node, urlParts, index + 1); + if (result !== null) { + result.params[node.dynamicChild.paramName] = segment; + return result; + } + } + + // 3. Try catch-all (1+ remaining segments) + if (node.catchAllChild !== null) { + const remaining = urlParts.slice(index); + const params: Record = Object.create(null); + params[node.catchAllChild.paramName] = remaining; + return { route: node.catchAllChild.route, params }; + } + + // 4. Try optional catch-all (0+ remaining segments) + if (node.optionalCatchAllChild !== null) { + const remaining = urlParts.slice(index); + const params: Record = Object.create(null); + params[node.optionalCatchAllChild.paramName] = remaining; + return { route: node.optionalCatchAllChild.route, params }; + } + + return null; +} diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index ddc94f5a..f1be806d 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -357,6 +357,7 @@ import { validateCsrfOrigin, validateImageUrl, guardProtocolRelativeUrl, hasBase import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache"; +import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; // Import server-only state module to register ALS-backed accessors. import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state"; @@ -760,6 +761,7 @@ const routes = [ unauthorized: null, } ]; +const _routeTrie = _buildRouteTrie(routes); const metadataRoutes = [ @@ -1025,20 +1027,17 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc }); } -function matchRoute(url, routes) { +function matchRoute(url) { const pathname = url.split("?")[0]; let normalizedUrl = pathname === "/" ? "/" : pathname.replace(/\\/$/, ""); // NOTE: Do NOT decodeURIComponent here. The caller is responsible for decoding // the pathname exactly once at the request entry point. Decoding again here // would cause inconsistent path matching between middleware and routing. const urlParts = normalizedUrl.split("/").filter(Boolean); - for (const route of routes) { - const params = matchPattern(urlParts, route.patternParts); - if (params !== null) return { route, params }; - } - return null; + return _trieMatch(_routeTrie, urlParts); } +// matchPattern is kept for findIntercept (linear scan over small interceptLookup array). function matchPattern(urlParts, patternParts) { const params = Object.create(null); for (let i = 0; i < patternParts.length; i++) { @@ -1972,7 +1971,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // After the action, re-render the current page so the client // gets an updated React tree reflecting any mutations. - const match = matchRoute(cleanPathname, routes); + const match = matchRoute(cleanPathname); let element; if (match) { const { route: actionRoute, params: actionParams } = match; @@ -2047,7 +2046,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } - let match = matchRoute(cleanPathname, routes); + let match = matchRoute(cleanPathname); // ── Fallback rewrites from next.config.js (if no route matched) ─────── if (!match && __configRewrites.fallback && __configRewrites.fallback.length) { @@ -2059,7 +2058,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return proxyExternalRequest(request, __fallbackRewritten); } cleanPathname = __fallbackRewritten; - match = matchRoute(cleanPathname, routes); + match = matchRoute(cleanPathname); } } @@ -2495,7 +2494,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const sourceRoute = routes[intercept.sourceRouteIndex]; if (sourceRoute && sourceRoute !== route) { // Render the source route (e.g. /feed) with the intercepting page in the slot - const sourceMatch = matchRoute(sourceRoute.pattern, routes); + const sourceMatch = matchRoute(sourceRoute.pattern); const sourceParams = sourceMatch ? sourceMatch.params : {}; setNavigationContext({ pathname: cleanPathname, @@ -3104,6 +3103,7 @@ import { validateCsrfOrigin, validateImageUrl, guardProtocolRelativeUrl, hasBase import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache"; +import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; // Import server-only state module to register ALS-backed accessors. import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state"; @@ -3507,6 +3507,7 @@ const routes = [ unauthorized: null, } ]; +const _routeTrie = _buildRouteTrie(routes); const metadataRoutes = [ @@ -3772,20 +3773,17 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc }); } -function matchRoute(url, routes) { +function matchRoute(url) { const pathname = url.split("?")[0]; let normalizedUrl = pathname === "/" ? "/" : pathname.replace(/\\/$/, ""); // NOTE: Do NOT decodeURIComponent here. The caller is responsible for decoding // the pathname exactly once at the request entry point. Decoding again here // would cause inconsistent path matching between middleware and routing. const urlParts = normalizedUrl.split("/").filter(Boolean); - for (const route of routes) { - const params = matchPattern(urlParts, route.patternParts); - if (params !== null) return { route, params }; - } - return null; + return _trieMatch(_routeTrie, urlParts); } +// matchPattern is kept for findIntercept (linear scan over small interceptLookup array). function matchPattern(urlParts, patternParts) { const params = Object.create(null); for (let i = 0; i < patternParts.length; i++) { @@ -4722,7 +4720,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // After the action, re-render the current page so the client // gets an updated React tree reflecting any mutations. - const match = matchRoute(cleanPathname, routes); + const match = matchRoute(cleanPathname); let element; if (match) { const { route: actionRoute, params: actionParams } = match; @@ -4797,7 +4795,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } - let match = matchRoute(cleanPathname, routes); + let match = matchRoute(cleanPathname); // ── Fallback rewrites from next.config.js (if no route matched) ─────── if (!match && __configRewrites.fallback && __configRewrites.fallback.length) { @@ -4809,7 +4807,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return proxyExternalRequest(request, __fallbackRewritten); } cleanPathname = __fallbackRewritten; - match = matchRoute(cleanPathname, routes); + match = matchRoute(cleanPathname); } } @@ -5245,7 +5243,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const sourceRoute = routes[intercept.sourceRouteIndex]; if (sourceRoute && sourceRoute !== route) { // Render the source route (e.g. /feed) with the intercepting page in the slot - const sourceMatch = matchRoute(sourceRoute.pattern, routes); + const sourceMatch = matchRoute(sourceRoute.pattern); const sourceParams = sourceMatch ? sourceMatch.params : {}; setNavigationContext({ pathname: cleanPathname, @@ -5854,6 +5852,7 @@ import { validateCsrfOrigin, validateImageUrl, guardProtocolRelativeUrl, hasBase import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache"; +import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; // Import server-only state module to register ALS-backed accessors. import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state"; @@ -6258,6 +6257,7 @@ const routes = [ unauthorized: null, } ]; +const _routeTrie = _buildRouteTrie(routes); const metadataRoutes = [ @@ -6544,20 +6544,17 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc }); } -function matchRoute(url, routes) { +function matchRoute(url) { const pathname = url.split("?")[0]; let normalizedUrl = pathname === "/" ? "/" : pathname.replace(/\\/$/, ""); // NOTE: Do NOT decodeURIComponent here. The caller is responsible for decoding // the pathname exactly once at the request entry point. Decoding again here // would cause inconsistent path matching between middleware and routing. const urlParts = normalizedUrl.split("/").filter(Boolean); - for (const route of routes) { - const params = matchPattern(urlParts, route.patternParts); - if (params !== null) return { route, params }; - } - return null; + return _trieMatch(_routeTrie, urlParts); } +// matchPattern is kept for findIntercept (linear scan over small interceptLookup array). function matchPattern(urlParts, patternParts) { const params = Object.create(null); for (let i = 0; i < patternParts.length; i++) { @@ -7499,7 +7496,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // After the action, re-render the current page so the client // gets an updated React tree reflecting any mutations. - const match = matchRoute(cleanPathname, routes); + const match = matchRoute(cleanPathname); let element; if (match) { const { route: actionRoute, params: actionParams } = match; @@ -7574,7 +7571,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } - let match = matchRoute(cleanPathname, routes); + let match = matchRoute(cleanPathname); // ── Fallback rewrites from next.config.js (if no route matched) ─────── if (!match && __configRewrites.fallback && __configRewrites.fallback.length) { @@ -7586,7 +7583,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return proxyExternalRequest(request, __fallbackRewritten); } cleanPathname = __fallbackRewritten; - match = matchRoute(cleanPathname, routes); + match = matchRoute(cleanPathname); } } @@ -8022,7 +8019,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const sourceRoute = routes[intercept.sourceRouteIndex]; if (sourceRoute && sourceRoute !== route) { // Render the source route (e.g. /feed) with the intercepting page in the slot - const sourceMatch = matchRoute(sourceRoute.pattern, routes); + const sourceMatch = matchRoute(sourceRoute.pattern); const sourceParams = sourceMatch ? sourceMatch.params : {}; setNavigationContext({ pathname: cleanPathname, @@ -8639,6 +8636,7 @@ import { validateCsrfOrigin, validateImageUrl, guardProtocolRelativeUrl, hasBase import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache"; +import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; // Import server-only state module to register ALS-backed accessors. import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state"; @@ -9071,6 +9069,7 @@ const routes = [ unauthorized: null, } ]; +const _routeTrie = _buildRouteTrie(routes); const metadataRoutes = [ @@ -9336,20 +9335,17 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc }); } -function matchRoute(url, routes) { +function matchRoute(url) { const pathname = url.split("?")[0]; let normalizedUrl = pathname === "/" ? "/" : pathname.replace(/\\/$/, ""); // NOTE: Do NOT decodeURIComponent here. The caller is responsible for decoding // the pathname exactly once at the request entry point. Decoding again here // would cause inconsistent path matching between middleware and routing. const urlParts = normalizedUrl.split("/").filter(Boolean); - for (const route of routes) { - const params = matchPattern(urlParts, route.patternParts); - if (params !== null) return { route, params }; - } - return null; + return _trieMatch(_routeTrie, urlParts); } +// matchPattern is kept for findIntercept (linear scan over small interceptLookup array). function matchPattern(urlParts, patternParts) { const params = Object.create(null); for (let i = 0; i < patternParts.length; i++) { @@ -10286,7 +10282,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // After the action, re-render the current page so the client // gets an updated React tree reflecting any mutations. - const match = matchRoute(cleanPathname, routes); + const match = matchRoute(cleanPathname); let element; if (match) { const { route: actionRoute, params: actionParams } = match; @@ -10361,7 +10357,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } - let match = matchRoute(cleanPathname, routes); + let match = matchRoute(cleanPathname); // ── Fallback rewrites from next.config.js (if no route matched) ─────── if (!match && __configRewrites.fallback && __configRewrites.fallback.length) { @@ -10373,7 +10369,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return proxyExternalRequest(request, __fallbackRewritten); } cleanPathname = __fallbackRewritten; - match = matchRoute(cleanPathname, routes); + match = matchRoute(cleanPathname); } } @@ -10809,7 +10805,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const sourceRoute = routes[intercept.sourceRouteIndex]; if (sourceRoute && sourceRoute !== route) { // Render the source route (e.g. /feed) with the intercepting page in the slot - const sourceMatch = matchRoute(sourceRoute.pattern, routes); + const sourceMatch = matchRoute(sourceRoute.pattern); const sourceParams = sourceMatch ? sourceMatch.params : {}; setNavigationContext({ pathname: cleanPathname, @@ -11418,6 +11414,7 @@ import { validateCsrfOrigin, validateImageUrl, guardProtocolRelativeUrl, hasBase import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache"; +import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; // Import server-only state module to register ALS-backed accessors. import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state"; @@ -11822,6 +11819,7 @@ const routes = [ unauthorized: null, } ]; +const _routeTrie = _buildRouteTrie(routes); const metadataRoutes = [ { @@ -12093,20 +12091,17 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc }); } -function matchRoute(url, routes) { +function matchRoute(url) { const pathname = url.split("?")[0]; let normalizedUrl = pathname === "/" ? "/" : pathname.replace(/\\/$/, ""); // NOTE: Do NOT decodeURIComponent here. The caller is responsible for decoding // the pathname exactly once at the request entry point. Decoding again here // would cause inconsistent path matching between middleware and routing. const urlParts = normalizedUrl.split("/").filter(Boolean); - for (const route of routes) { - const params = matchPattern(urlParts, route.patternParts); - if (params !== null) return { route, params }; - } - return null; + return _trieMatch(_routeTrie, urlParts); } +// matchPattern is kept for findIntercept (linear scan over small interceptLookup array). function matchPattern(urlParts, patternParts) { const params = Object.create(null); for (let i = 0; i < patternParts.length; i++) { @@ -13040,7 +13035,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // After the action, re-render the current page so the client // gets an updated React tree reflecting any mutations. - const match = matchRoute(cleanPathname, routes); + const match = matchRoute(cleanPathname); let element; if (match) { const { route: actionRoute, params: actionParams } = match; @@ -13115,7 +13110,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } - let match = matchRoute(cleanPathname, routes); + let match = matchRoute(cleanPathname); // ── Fallback rewrites from next.config.js (if no route matched) ─────── if (!match && __configRewrites.fallback && __configRewrites.fallback.length) { @@ -13127,7 +13122,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return proxyExternalRequest(request, __fallbackRewritten); } cleanPathname = __fallbackRewritten; - match = matchRoute(cleanPathname, routes); + match = matchRoute(cleanPathname); } } @@ -13563,7 +13558,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const sourceRoute = routes[intercept.sourceRouteIndex]; if (sourceRoute && sourceRoute !== route) { // Render the source route (e.g. /feed) with the intercepting page in the slot - const sourceMatch = matchRoute(sourceRoute.pattern, routes); + const sourceMatch = matchRoute(sourceRoute.pattern); const sourceParams = sourceMatch ? sourceMatch.params : {}; setNavigationContext({ pathname: cleanPathname, @@ -14172,6 +14167,7 @@ import { validateCsrfOrigin, validateImageUrl, guardProtocolRelativeUrl, hasBase import { _consumeRequestScopedCacheLife, _runWithCacheState, getCacheHandler } from "next/cache"; import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; import { getCollectedFetchTags, runWithFetchCache } from "vinext/fetch-cache"; +import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; // Import server-only state module to register ALS-backed accessors. import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state"; @@ -14575,6 +14571,7 @@ const routes = [ unauthorized: null, } ]; +const _routeTrie = _buildRouteTrie(routes); const metadataRoutes = [ @@ -14840,20 +14837,17 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc }); } -function matchRoute(url, routes) { +function matchRoute(url) { const pathname = url.split("?")[0]; let normalizedUrl = pathname === "/" ? "/" : pathname.replace(/\\/$/, ""); // NOTE: Do NOT decodeURIComponent here. The caller is responsible for decoding // the pathname exactly once at the request entry point. Decoding again here // would cause inconsistent path matching between middleware and routing. const urlParts = normalizedUrl.split("/").filter(Boolean); - for (const route of routes) { - const params = matchPattern(urlParts, route.patternParts); - if (params !== null) return { route, params }; - } - return null; + return _trieMatch(_routeTrie, urlParts); } +// matchPattern is kept for findIntercept (linear scan over small interceptLookup array). function matchPattern(urlParts, patternParts) { const params = Object.create(null); for (let i = 0; i < patternParts.length; i++) { @@ -16065,7 +16059,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // After the action, re-render the current page so the client // gets an updated React tree reflecting any mutations. - const match = matchRoute(cleanPathname, routes); + const match = matchRoute(cleanPathname); let element; if (match) { const { route: actionRoute, params: actionParams } = match; @@ -16140,7 +16134,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } - let match = matchRoute(cleanPathname, routes); + let match = matchRoute(cleanPathname); // ── Fallback rewrites from next.config.js (if no route matched) ─────── if (!match && __configRewrites.fallback && __configRewrites.fallback.length) { @@ -16152,7 +16146,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return proxyExternalRequest(request, __fallbackRewritten); } cleanPathname = __fallbackRewritten; - match = matchRoute(cleanPathname, routes); + match = matchRoute(cleanPathname); } } @@ -16588,7 +16582,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const sourceRoute = routes[intercept.sourceRouteIndex]; if (sourceRoute && sourceRoute !== route) { // Render the source route (e.g. /feed) with the intercepting page in the slot - const sourceMatch = matchRoute(sourceRoute.pattern, routes); + const sourceMatch = matchRoute(sourceRoute.pattern); const sourceParams = sourceMatch ? sourceMatch.params : {}; setNavigationContext({ pathname: cleanPathname, @@ -17730,6 +17724,7 @@ import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontSty import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; import { parseCookies } from "/packages/vinext/src/config/config-matchers.js"; import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; +import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; import * as _instrumentation from "/tests/fixtures/pages-basic/instrumentation.ts"; import * as middlewareModule from "/tests/fixtures/pages-basic/middleware.ts"; import { NextRequest, NextFetchEvent } from "next/server"; @@ -17906,6 +17901,7 @@ const pageRoutes = [ { pattern: "/docs/:slug+", patternParts: ["docs",":slug+"], isDynamic: true, params: ["slug"], module: page_30, filePath: "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx" }, { pattern: "/sign-up/:sign-up*", patternParts: ["sign-up",":sign-up*"], isDynamic: true, params: ["sign-up"], module: page_31, filePath: "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx" } ]; +const _pageRouteTrie = _buildRouteTrie(pageRoutes); const apiRoutes = [ { pattern: "/api/binary", patternParts: ["api","binary"], isDynamic: false, params: [], module: api_0 }, @@ -17919,6 +17915,7 @@ const apiRoutes = [ { pattern: "/api/send-buffer", patternParts: ["api","send-buffer"], isDynamic: false, params: [], module: api_8 }, { pattern: "/api/users/:id", patternParts: ["api","users",":id"], isDynamic: true, params: ["id"], module: api_9 } ]; +const _apiRouteTrie = _buildRouteTrie(apiRoutes); function matchRoute(url, routes) { const pathname = url.split("?")[0]; @@ -17926,40 +17923,8 @@ function matchRoute(url, routes) { // NOTE: Do NOT decodeURIComponent here. The pathname is already decoded at // the entry point. Decoding again would create a double-decode vector. const urlParts = normalizedUrl.split("/").filter(Boolean); - for (const route of routes) { - const params = matchPattern(urlParts, route.patternParts); - if (params !== null) return { route, params }; - } - return null; -} - -function matchPattern(urlParts, patternParts) { - const params = Object.create(null); - for (let i = 0; i < patternParts.length; i++) { - const pp = patternParts[i]; - if (pp.endsWith("+")) { - if (i !== patternParts.length - 1) return null; - const paramName = pp.slice(1, -1); - const remaining = urlParts.slice(i); - if (remaining.length === 0) return null; - params[paramName] = remaining; - return params; - } - if (pp.endsWith("*")) { - if (i !== patternParts.length - 1) return null; - const paramName = pp.slice(1, -1); - params[paramName] = urlParts.slice(i); - return params; - } - if (pp.startsWith(":")) { - if (i >= urlParts.length) return null; - params[pp.slice(1)] = urlParts[i]; - continue; - } - if (i >= urlParts.length || urlParts[i] !== pp) return null; - } - if (urlParts.length !== patternParts.length) return null; - return params; + const trie = routes === pageRoutes ? _pageRouteTrie : _apiRouteTrie; + return _trieMatch(trie, urlParts); } function parseQuery(url) { diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index 0e63dc5b..efa1e063 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -277,9 +277,10 @@ describe("Pages Router entry templates", () => { expect(stabilize(code)).toMatchSnapshot(); }); - it("server entry rejects malformed non-terminal catch-all patterns", async () => { + it("server entry uses trie-based route matching", async () => { const code = await getVirtualModuleCode("virtual:vinext-server-entry"); - expect(stabilize(code)).toContain("if (i !== patternParts.length - 1) return null;"); + expect(stabilize(code)).toContain("buildRouteTrie"); + expect(stabilize(code)).toContain("trieMatch"); }); it("client entry snapshot", async () => { diff --git a/tests/route-trie.test.ts b/tests/route-trie.test.ts new file mode 100644 index 00000000..2a7c0e80 --- /dev/null +++ b/tests/route-trie.test.ts @@ -0,0 +1,433 @@ +import { describe, it, expect } from "vitest"; +import { buildRouteTrie, trieMatch } from "../packages/vinext/src/routing/route-trie.js"; + +interface TestRoute { + pattern: string; + patternParts: string[]; +} + +function r(pattern: string): TestRoute { + const parts = pattern === "/" ? [] : pattern.split("/").filter(Boolean); + // Convert parts to the internal format: [slug] → :slug, [...slug] → :slug+, [[...slug]] → :slug* + // But our test routes already use the internal :param format, so just use as-is + return { pattern, patternParts: parts }; +} + +describe("buildRouteTrie + trieMatch", () => { + describe("basic matching", () => { + it("matches root route", () => { + const trie = buildRouteTrie([r("/")]); + const result = trieMatch(trie, []); + expect(result).not.toBeNull(); + expect(result!.route.pattern).toBe("/"); + expect(result!.params).toEqual({}); + }); + + it("matches static routes", () => { + const routes = [r("/"), r("/about"), r("/blog")]; + const trie = buildRouteTrie(routes); + + expect(trieMatch(trie, ["about"])!.route.pattern).toBe("/about"); + expect(trieMatch(trie, ["blog"])!.route.pattern).toBe("/blog"); + expect(trieMatch(trie, [])!.route.pattern).toBe("/"); + }); + + it("matches nested static routes", () => { + const routes = [r("/blog/posts/featured")]; + const trie = buildRouteTrie(routes); + + expect(trieMatch(trie, ["blog", "posts", "featured"])!.route.pattern).toBe( + "/blog/posts/featured", + ); + expect(trieMatch(trie, ["blog", "posts"])).toBeNull(); + expect(trieMatch(trie, ["blog"])).toBeNull(); + }); + + it("returns null for no match", () => { + const routes = [r("/about")]; + const trie = buildRouteTrie(routes); + + expect(trieMatch(trie, ["contact"])).toBeNull(); + expect(trieMatch(trie, ["about", "team"])).toBeNull(); + }); + }); + + describe("dynamic segments", () => { + it("matches single dynamic segment", () => { + const routes = [r("/blog/:slug")]; + const trie = buildRouteTrie(routes); + + const result = trieMatch(trie, ["blog", "hello-world"]); + expect(result).not.toBeNull(); + expect(result!.route.pattern).toBe("/blog/:slug"); + expect(result!.params).toEqual({ slug: "hello-world" }); + }); + + it("matches multiple dynamic segments", () => { + const routes = [r("/:a/:b/:c")]; + const trie = buildRouteTrie(routes); + + const result = trieMatch(trie, ["x", "y", "z"]); + expect(result).not.toBeNull(); + expect(result!.params).toEqual({ a: "x", b: "y", c: "z" }); + }); + + it("does not match dynamic segment with extra path", () => { + const routes = [r("/blog/:slug")]; + const trie = buildRouteTrie(routes); + + expect(trieMatch(trie, ["blog", "hello", "extra"])).toBeNull(); + }); + + it("does not match dynamic segment with missing segment", () => { + const routes = [r("/blog/:slug")]; + const trie = buildRouteTrie(routes); + + expect(trieMatch(trie, ["blog"])).toBeNull(); + }); + }); + + describe("catch-all routes", () => { + it("matches catch-all with one segment", () => { + const routes = [r("/docs/:path+")]; + const trie = buildRouteTrie(routes); + + const result = trieMatch(trie, ["docs", "intro"]); + expect(result).not.toBeNull(); + expect(result!.params).toEqual({ path: ["intro"] }); + }); + + it("matches catch-all with multiple segments", () => { + const routes = [r("/docs/:path+")]; + const trie = buildRouteTrie(routes); + + const result = trieMatch(trie, ["docs", "api", "reference", "v2"]); + expect(result).not.toBeNull(); + expect(result!.params).toEqual({ path: ["api", "reference", "v2"] }); + }); + + it("does not match catch-all with zero segments", () => { + const routes = [r("/docs/:path+")]; + const trie = buildRouteTrie(routes); + + expect(trieMatch(trie, ["docs"])).toBeNull(); + }); + }); + + describe("optional catch-all routes", () => { + it("matches optional catch-all with zero segments", () => { + const routes = [r("/docs/:path*")]; + const trie = buildRouteTrie(routes); + + const result = trieMatch(trie, ["docs"]); + expect(result).not.toBeNull(); + expect(result!.params).toEqual({ path: [] }); + }); + + it("matches optional catch-all with one segment", () => { + const routes = [r("/docs/:path*")]; + const trie = buildRouteTrie(routes); + + const result = trieMatch(trie, ["docs", "intro"]); + expect(result).not.toBeNull(); + expect(result!.params).toEqual({ path: ["intro"] }); + }); + + it("matches optional catch-all with multiple segments", () => { + const routes = [r("/docs/:path*")]; + const trie = buildRouteTrie(routes); + + const result = trieMatch(trie, ["docs", "api", "ref"]); + expect(result).not.toBeNull(); + expect(result!.params).toEqual({ path: ["api", "ref"] }); + }); + }); + + describe("priority / precedence", () => { + it("static wins over dynamic", () => { + // Pre-sorted: static first + const routes = [r("/blog/about"), r("/blog/:slug")]; + const trie = buildRouteTrie(routes); + + expect(trieMatch(trie, ["blog", "about"])!.route.pattern).toBe("/blog/about"); + expect(trieMatch(trie, ["blog", "other"])!.route.pattern).toBe("/blog/:slug"); + }); + + it("dynamic wins over catch-all", () => { + const routes = [r("/blog/:id/comments"), r("/blog/:path+")]; + const trie = buildRouteTrie(routes); + + expect(trieMatch(trie, ["blog", "42", "comments"])!.route.pattern).toBe("/blog/:id/comments"); + expect(trieMatch(trie, ["blog", "42", "comments"])!.params).toEqual({ + id: "42", + }); + }); + + it("catch-all wins over optional catch-all", () => { + const routes = [r("/docs/:path+"), r("/docs/:slug*")]; + const trie = buildRouteTrie(routes); + + // With segments: catch-all wins + expect(trieMatch(trie, ["docs", "intro"])!.route.pattern).toBe("/docs/:path+"); + // With zero segments: only optional catch-all matches + expect(trieMatch(trie, ["docs"])!.route.pattern).toBe("/docs/:slug*"); + }); + }); + + describe("backtracking", () => { + it("backtracks from dynamic dead-end to catch-all", () => { + const routes = [r("/blog/:id/comments"), r("/blog/:path+")]; + const trie = buildRouteTrie(routes); + + // /blog/42/photos → dynamic :id matches "42", but "photos" has no match + // → backtrack to catch-all :path+ which matches ["42", "photos"] + const result = trieMatch(trie, ["blog", "42", "photos"]); + expect(result).not.toBeNull(); + expect(result!.route.pattern).toBe("/blog/:path+"); + expect(result!.params).toEqual({ path: ["42", "photos"] }); + }); + + it("backtracks from static dead-end to dynamic", () => { + const routes = [r("/api/users/me"), r("/api/:resource/:id")]; + const trie = buildRouteTrie(routes); + + // /api/users/me is fully static. /api/:resource/:id is fully dynamic. + // For /api/users/123: api → users matches the static branch, but 123 ≠ "me", + // so that branch is a dead-end and the matcher backtracks to api → :resource → :id. + const result = trieMatch(trie, ["api", "users", "123"]); + expect(result).not.toBeNull(); + expect(result!.route.pattern).toBe("/api/:resource/:id"); + expect(result!.params).toEqual({ resource: "users", id: "123" }); + + // /api/users/me should still prefer the more specific static route + const meResult = trieMatch(trie, ["api", "users", "me"]); + expect(meResult!.route.pattern).toBe("/api/users/me"); + }); + + it("backtracks multiple levels", () => { + const routes = [ + r("/a/b/c/d"), // most specific + r("/a/b/:x"), // dynamic at depth 3 + r("/a/:y+"), // catch-all at depth 2 + ]; + const trie = buildRouteTrie(routes); + + // /a/b/c/d → exact static match + expect(trieMatch(trie, ["a", "b", "c", "d"])!.route.pattern).toBe("/a/b/c/d"); + + // /a/b/c → static a -> static b -> static c -> no route (d is missing) + // → backtrack: a -> b -> :x matches "c" → has route ✓ + expect(trieMatch(trie, ["a", "b", "c"])!.route.pattern).toBe("/a/b/:x"); + expect(trieMatch(trie, ["a", "b", "c"])!.params).toEqual({ x: "c" }); + + // /a/b/c/e → static a -> b -> c -> no "e" → backtrack to a -> b -> :x -> only matches 1 segment + // → backtrack to a -> :y+ matches ["b", "c", "e"] + expect(trieMatch(trie, ["a", "b", "c", "e"])!.route.pattern).toBe("/a/:y+"); + expect(trieMatch(trie, ["a", "b", "c", "e"])!.params).toEqual({ + y: ["a", "b", "c", "e"].slice(1), + }); + }); + }); + + describe("deeply nested routes", () => { + it("handles 5+ segment routes", () => { + const routes = [r("/a/b/c/d/e/f")]; + const trie = buildRouteTrie(routes); + + expect(trieMatch(trie, ["a", "b", "c", "d", "e", "f"])!.route.pattern).toBe("/a/b/c/d/e/f"); + expect(trieMatch(trie, ["a", "b", "c", "d", "e"])).toBeNull(); + }); + }); + + describe("root-level catch-alls", () => { + it("matches root-level optional catch-all", () => { + const routes = [r("/"), r("/:path*")]; + const trie = buildRouteTrie(routes); + + expect(trieMatch(trie, [])!.route.pattern).toBe("/"); + expect(trieMatch(trie, ["anything"])!.route.pattern).toBe("/:path*"); + expect(trieMatch(trie, ["a", "b"])!.params).toEqual({ path: ["a", "b"] }); + }); + + it("matches root-level catch-all", () => { + const routes = [r("/"), r("/:path+")]; + const trie = buildRouteTrie(routes); + + expect(trieMatch(trie, [])!.route.pattern).toBe("/"); + expect(trieMatch(trie, ["anything"])!.route.pattern).toBe("/:path+"); + }); + }); + + describe("parity with linear matchPattern", () => { + // This is the critical parity test: generate many routes and URLs, + // run both the trie and the linear scan, assert identical results. + + function linearMatchPattern( + urlParts: string[], + patternParts: string[], + ): Record | null { + const params: Record = Object.create(null); + for (let i = 0; i < patternParts.length; i++) { + const pp = patternParts[i]; + if (pp.endsWith("+")) { + if (i !== patternParts.length - 1) return null; + const paramName = pp.slice(1, -1); + const remaining = urlParts.slice(i); + if (remaining.length === 0) return null; + params[paramName] = remaining; + return params; + } + if (pp.endsWith("*")) { + if (i !== patternParts.length - 1) return null; + const paramName = pp.slice(1, -1); + params[paramName] = urlParts.slice(i); + return params; + } + if (pp.startsWith(":")) { + const paramName = pp.slice(1); + if (i >= urlParts.length) return null; + params[paramName] = urlParts[i]; + continue; + } + if (i >= urlParts.length || urlParts[i] !== pp) return null; + } + if (urlParts.length !== patternParts.length) return null; + return params; + } + + function linearMatchRoute( + urlParts: string[], + routes: TestRoute[], + ): { route: TestRoute; params: Record } | null { + for (const route of routes) { + const params = linearMatchPattern(urlParts, route.patternParts); + if (params !== null) return { route, params }; + } + return null; + } + + // Routes are pre-sorted by precedence (static > dynamic > catch-all > optional catch-all), + // which is how the real codebase provides them. The trie and linear scan must agree + // when routes are in this order. Note: all dynamic segments at the same depth + // must share the same param name (enforced by validateRoutePatterns in production). + const parityRoutes: TestRoute[] = [ + // Static routes (sorted alphabetically, shorter first) + r("/"), + r("/about"), + r("/a/b/c/d/e"), + r("/api/health"), + r("/api/users/me"), + r("/blog"), + r("/blog/archive"), + r("/blog/featured"), + r("/settings/notifications"), + r("/settings/profile"), + // Static prefix + dynamic suffix + r("/api/users/:id"), + r("/blog/:slug"), + r("/products/:category/:id"), + // Static prefix + catch-all + r("/docs/:path+"), + r("/files/:rest+"), + // Static prefix + optional catch-all + r("/shop/:path*"), + r("/wiki/:slug*"), + ]; + + const testUrls: string[][] = [ + [], + ["about"], + ["blog"], + ["blog", "featured"], + ["blog", "archive"], + ["blog", "hello-world"], + ["blog", "my-post"], + ["api", "health"], + ["api", "users", "me"], + ["api", "users", "42"], + ["api", "users", "abc"], + ["settings", "profile"], + ["settings", "notifications"], + ["settings", "other"], + ["a", "b", "c", "d", "e"], + ["products", "electronics", "123"], + ["products", "books", "456"], + ["docs", "intro"], + ["docs", "api", "reference"], + ["docs", "api", "reference", "v2"], + ["files", "images", "photo.jpg"], + ["shop"], + ["shop", "electronics"], + ["shop", "electronics", "phones"], + ["wiki"], + ["wiki", "page"], + ["wiki", "nested", "page"], + ["nonexistent"], + ["a"], + ["a", "b"], + ["deeply", "nested", "path", "that", "matches", "nothing"], + ]; + + const trie = buildRouteTrie(parityRoutes); + + for (const urlParts of testUrls) { + const urlStr = "/" + urlParts.join("/"); + it(`parity: ${urlStr}`, () => { + const trieResult = trieMatch(trie, urlParts); + const linearResult = linearMatchRoute(urlParts, parityRoutes); + + if (linearResult === null) { + expect(trieResult).toBeNull(); + } else { + expect(trieResult).not.toBeNull(); + expect(trieResult!.route.pattern).toBe(linearResult.route.pattern); + + // Compare params — normalize for comparison + const trieParams = { ...trieResult!.params }; + const linearParams = { ...linearResult.params }; + expect(trieParams).toEqual(linearParams); + } + }); + } + }); + + describe("edge cases", () => { + it("handles param names with hyphens", () => { + const routes = [r("/blog/:post-id")]; + const trie = buildRouteTrie(routes); + + const result = trieMatch(trie, ["blog", "42"]); + expect(result).not.toBeNull(); + expect(result!.params).toEqual({ "post-id": "42" }); + }); + + it("handles empty trie (no routes)", () => { + const trie = buildRouteTrie([]); + expect(trieMatch(trie, [])).toBeNull(); + expect(trieMatch(trie, ["anything"])).toBeNull(); + }); + + it("handles single root route only", () => { + const trie = buildRouteTrie([r("/")]); + expect(trieMatch(trie, [])!.route.pattern).toBe("/"); + expect(trieMatch(trie, ["anything"])).toBeNull(); + }); + + it("static route with same prefix as dynamic does not leak", () => { + const routes = [r("/api/v1"), r("/api/:version")]; + const trie = buildRouteTrie(routes); + + expect(trieMatch(trie, ["api", "v1"])!.route.pattern).toBe("/api/v1"); + expect(trieMatch(trie, ["api", "v2"])!.route.pattern).toBe("/api/:version"); + }); + + it("first route wins for duplicate patterns", () => { + const route1: TestRoute = { pattern: "/first", patternParts: ["first"] }; + const route2: TestRoute = { pattern: "/first", patternParts: ["first"] }; + const trie = buildRouteTrie([route1, route2]); + + const result = trieMatch(trie, ["first"]); + expect(result!.route).toBe(route1); + }); + }); +}); diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 5967241b..f52e0e24 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -2734,7 +2734,7 @@ describe("double-encoded path handling in middleware", () => { }, ]); // Extract the matchRoute function from generated code - const matchRouteMatch = code.match(/function matchRoute\(url, routes\) \{[\s\S]*?\n\}/); + const matchRouteMatch = code.match(/function matchRoute\(url\) \{[\s\S]*?\n\}/); expect(matchRouteMatch).toBeTruthy(); const matchRouteCode = matchRouteMatch![0]; // Verify it does NOT call decodeURIComponent (the comment mentions it but