From 1c9f2c5580fd87363cb90dba30f92b83e4e552c6 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 10 Mar 2026 19:40:15 -0700 Subject: [PATCH 1/4] perf: replace O(n) linear route matching with radix trie Every request triggered 3-6 linear scans over the full route list, calling matchPattern() for every route until a match. For apps with many routes this means thousands of comparisons per request. This replaces the linear scan with a trie-based O(depth) lookup in all 4 codebases that share the matching logic: - pages-router.ts (scanner module, dev time) - app-router.ts (scanner module, dev time) - app-rsc-entry.ts (generated entry, runtime) - pages-server-entry.ts (generated entry, runtime) The trie enforces correct precedence at each node via traversal order: static > dynamic > catch-all > optional catch-all. Recursive DFS with backtracking ensures dead-end branches fall through to alternatives. matchPattern is retained only in app-rsc-entry.ts for the intercept lookup (typically 0-5 entries, not worth trie-ifying). --- packages/vinext/src/entries/app-rsc-entry.ts | 13 +- .../vinext/src/entries/pages-server-entry.ts | 43 +- packages/vinext/src/routing/app-router.ts | 67 +-- packages/vinext/src/routing/pages-router.ts | 72 +-- packages/vinext/src/routing/route-trie.ts | 195 ++++++++ tests/route-trie.test.ts | 437 ++++++++++++++++++ 6 files changed, 681 insertions(+), 146 deletions(-) create mode 100644 packages/vinext/src/routing/route-trie.ts create mode 100644 tests/route-trie.test.ts diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 5d43c20f..ad42a3b8 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 { 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"; @@ -590,6 +595,7 @@ async function __ensureInstrumentation() { const routes = [ ${routeEntries.join(",\n")} ]; +const _routeTrie = _buildRouteTrie(routes); const metadataRoutes = [ ${metaRouteEntries.join(",\n")} @@ -895,13 +901,10 @@ function matchRoute(url, routes) { // 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++) { diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index d41a5e80..e90f5a82 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. @@ -263,6 +267,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} @@ -339,10 +344,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]; @@ -350,40 +357,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 fce569f3..76c08d0a 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: "." | ".." | "../.." | "..." */ @@ -947,6 +948,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; +} + /** * Match a URL against App Router routes. */ @@ -962,56 +975,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..f2f1950f --- /dev/null +++ b/packages/vinext/src/routing/route-trie.ts @@ -0,0 +1,195 @@ +/** + * Radix trie 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/route-trie.test.ts b/tests/route-trie.test.ts new file mode 100644 index 00000000..1ae86dc8 --- /dev/null +++ b/tests/route-trie.test.ts @@ -0,0 +1,437 @@ +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); + + // "users" matches static, but "123" doesn't match "me" → backtrack + // Actually "users" → static child exists, "123" has no static child → go to dynamic + // Wait, this isn't backtracking. Let me think... + // /api/users/me is the static path. /api/users/123 should match dynamic. + // The trie has: api -> users (static) -> me (static), and api -> :resource (dynamic) -> :id (dynamic) + // For /api/users/123: api(static) -> users(static) -> 123 not "me" → no static child + // → no dynamic child at that node → backtrack to api -> :resource(dynamic) matches "users" -> :id matches "123" + 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" }); + + // But /api/users/me should still match static + 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); + }); + }); +}); From b1de57aed97e9f9957282c0d8e81d2d42ab4c571 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 10 Mar 2026 20:54:20 -0700 Subject: [PATCH 2/4] test: update entry-templates snapshot and assertion for trie-based matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server entry no longer inlines matchPattern — it imports the trie module instead. Update the explicit assertion to check for buildRouteTrie and trieMatch, and regenerate all entry template snapshots. --- .../entry-templates.test.ts.snap | 93 ++++++------------- tests/entry-templates.test.ts | 5 +- 2 files changed, 32 insertions(+), 66 deletions(-) diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index cb23dad7..40855672 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 { 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"; @@ -751,6 +752,7 @@ const routes = [ unauthorized: null, } ]; +const _routeTrie = _buildRouteTrie(routes); const metadataRoutes = [ @@ -1023,13 +1025,10 @@ function matchRoute(url, routes) { // 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++) { @@ -3069,6 +3068,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 { 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"; @@ -3463,6 +3463,7 @@ const routes = [ unauthorized: null, } ]; +const _routeTrie = _buildRouteTrie(routes); const metadataRoutes = [ @@ -3735,13 +3736,10 @@ function matchRoute(url, routes) { // 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++) { @@ -5784,6 +5782,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 { 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"; @@ -6179,6 +6178,7 @@ const routes = [ unauthorized: null, } ]; +const _routeTrie = _buildRouteTrie(routes); const metadataRoutes = [ @@ -6472,13 +6472,10 @@ function matchRoute(url, routes) { // 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++) { @@ -8534,6 +8531,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 { 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"; @@ -8957,6 +8955,7 @@ const routes = [ unauthorized: null, } ]; +const _routeTrie = _buildRouteTrie(routes); const metadataRoutes = [ @@ -9229,13 +9228,10 @@ function matchRoute(url, routes) { // 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++) { @@ -11278,6 +11274,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 { 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"; @@ -11673,6 +11670,7 @@ const routes = [ unauthorized: null, } ]; +const _routeTrie = _buildRouteTrie(routes); const metadataRoutes = [ { @@ -11951,13 +11949,10 @@ function matchRoute(url, routes) { // 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++) { @@ -13997,6 +13992,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 { 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"; @@ -14391,6 +14387,7 @@ const routes = [ unauthorized: null, } ]; +const _routeTrie = _buildRouteTrie(routes); const metadataRoutes = [ @@ -14663,13 +14660,10 @@ function matchRoute(url, routes) { // 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++) { @@ -17519,6 +17513,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"; @@ -17674,6 +17669,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 }, @@ -17684,6 +17680,7 @@ const apiRoutes = [ { pattern: "/api/no-content-type", patternParts: ["api","no-content-type"], isDynamic: false, params: [], module: api_5 }, { pattern: "/api/users/:id", patternParts: ["api","users",":id"], isDynamic: true, params: ["id"], module: api_6 } ]; +const _apiRouteTrie = _buildRouteTrie(apiRoutes); function matchRoute(url, routes) { const pathname = url.split("?")[0]; @@ -17691,40 +17688,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 () => { From f28487191219cfdc67276152d95fc0ec2210bcdb Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 10 Mar 2026 21:04:52 -0700 Subject: [PATCH 3/4] fix: address review comments on trie-based route matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Correct "radix trie" → "trie (prefix tree)" in doc comment (no path compression) - Remove unused `routes` parameter from `matchRoute` in app-rsc-entry - Replace stream-of-consciousness test comment with concise explanation --- packages/vinext/src/entries/app-rsc-entry.ts | 10 ++-- packages/vinext/src/routing/route-trie.ts | 2 +- .../entry-templates.test.ts.snap | 60 +++++++++---------- tests/route-trie.test.ts | 12 ++-- 4 files changed, 40 insertions(+), 44 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index ad42a3b8..57a66e6f 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -894,7 +894,7 @@ 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 @@ -1813,7 +1813,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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; @@ -1888,7 +1888,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { } } - 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) { @@ -1900,7 +1900,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { return proxyExternalRequest(request, __fallbackRewritten); } cleanPathname = __fallbackRewritten; - match = matchRoute(cleanPathname, routes); + match = matchRoute(cleanPathname); } } @@ -2312,7 +2312,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { 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/routing/route-trie.ts b/packages/vinext/src/routing/route-trie.ts index f2f1950f..9ef3b0ea 100644 --- a/packages/vinext/src/routing/route-trie.ts +++ b/packages/vinext/src/routing/route-trie.ts @@ -1,5 +1,5 @@ /** - * Radix trie for O(depth) route matching. + * 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: diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 40855672..cb704307 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -1018,7 +1018,7 @@ 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 @@ -1962,7 +1962,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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; @@ -2037,7 +2037,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { } } - 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) { @@ -2049,7 +2049,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { return proxyExternalRequest(request, __fallbackRewritten); } cleanPathname = __fallbackRewritten; - match = matchRoute(cleanPathname, routes); + match = matchRoute(cleanPathname); } } @@ -2461,7 +2461,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { 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, @@ -3729,7 +3729,7 @@ 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 @@ -4676,7 +4676,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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; @@ -4751,7 +4751,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { } } - 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) { @@ -4763,7 +4763,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { return proxyExternalRequest(request, __fallbackRewritten); } cleanPathname = __fallbackRewritten; - match = matchRoute(cleanPathname, routes); + match = matchRoute(cleanPathname); } } @@ -5175,7 +5175,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { 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, @@ -6465,7 +6465,7 @@ 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 @@ -7417,7 +7417,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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; @@ -7492,7 +7492,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { } } - 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) { @@ -7504,7 +7504,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { return proxyExternalRequest(request, __fallbackRewritten); } cleanPathname = __fallbackRewritten; - match = matchRoute(cleanPathname, routes); + match = matchRoute(cleanPathname); } } @@ -7916,7 +7916,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { 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, @@ -9221,7 +9221,7 @@ 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 @@ -10168,7 +10168,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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; @@ -10243,7 +10243,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { } } - 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) { @@ -10255,7 +10255,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { return proxyExternalRequest(request, __fallbackRewritten); } cleanPathname = __fallbackRewritten; - match = matchRoute(cleanPathname, routes); + match = matchRoute(cleanPathname); } } @@ -10667,7 +10667,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { 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, @@ -11942,7 +11942,7 @@ 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 @@ -12886,7 +12886,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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; @@ -12961,7 +12961,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { } } - 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) { @@ -12973,7 +12973,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { return proxyExternalRequest(request, __fallbackRewritten); } cleanPathname = __fallbackRewritten; - match = matchRoute(cleanPathname, routes); + match = matchRoute(cleanPathname); } } @@ -13385,7 +13385,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { 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, @@ -14653,7 +14653,7 @@ 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 @@ -15875,7 +15875,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { // 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; @@ -15950,7 +15950,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { } } - 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) { @@ -15962,7 +15962,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { return proxyExternalRequest(request, __fallbackRewritten); } cleanPathname = __fallbackRewritten; - match = matchRoute(cleanPathname, routes); + match = matchRoute(cleanPathname); } } @@ -16374,7 +16374,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { 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/tests/route-trie.test.ts b/tests/route-trie.test.ts index 1ae86dc8..2a7c0e80 100644 --- a/tests/route-trie.test.ts +++ b/tests/route-trie.test.ts @@ -191,19 +191,15 @@ describe("buildRouteTrie + trieMatch", () => { const routes = [r("/api/users/me"), r("/api/:resource/:id")]; const trie = buildRouteTrie(routes); - // "users" matches static, but "123" doesn't match "me" → backtrack - // Actually "users" → static child exists, "123" has no static child → go to dynamic - // Wait, this isn't backtracking. Let me think... - // /api/users/me is the static path. /api/users/123 should match dynamic. - // The trie has: api -> users (static) -> me (static), and api -> :resource (dynamic) -> :id (dynamic) - // For /api/users/123: api(static) -> users(static) -> 123 not "me" → no static child - // → no dynamic child at that node → backtrack to api -> :resource(dynamic) matches "users" -> :id matches "123" + // /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" }); - // But /api/users/me should still match static + // /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"); }); From fc2823d7532a94a0c7338e777babf0add90335b5 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 10 Mar 2026 21:37:18 -0700 Subject: [PATCH 4/4] fix: update matchRoute regex in test to match new 1-arg signature The test was asserting the old `matchRoute(url, routes)` signature in the generated code. Updated to match the new `matchRoute(url)` signature after the unused `routes` parameter was removed. --- tests/shims.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 0af73aa2..2d9c6506 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