Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -590,6 +595,7 @@ async function __ensureInstrumentation() {
const routes = [
${routeEntries.join(",\n")}
];
const _routeTrie = _buildRouteTrie(routes);

const metadataRoutes = [
${metaRouteEntries.join(",\n")}
Expand Down Expand Up @@ -888,20 +894,17 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc
});
}

function matchRoute(url, routes) {
function matchRoute(url) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Copilot flagged this and I agree: matchRoute(url) no longer takes a routes parameter but the function is called from _handleRequest which previously passed routes explicitly. The unused parameter was removed, which is good.

However, the function now implicitly closes over _routeTrie (a module-level constant). This is fine for the current use case where there's only one route set, but it means the function signature no longer communicates what it matches against. A one-line JSDoc comment would help future readers:

/** Match url against the prebuilt _routeTrie (module-level). */
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++) {
Expand Down Expand Up @@ -1810,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;
Expand Down Expand Up @@ -1885,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) {
Expand All @@ -1897,7 +1900,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) {
return proxyExternalRequest(request, __fallbackRewritten);
}
cleanPathname = __fallbackRewritten;
match = matchRoute(cleanPathname, routes);
match = matchRoute(cleanPathname);
}
}

Expand Down Expand Up @@ -2309,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,
Expand Down
43 changes: 9 additions & 34 deletions packages/vinext/src/entries/pages-server-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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}

Expand Down Expand Up @@ -339,51 +344,21 @@ ${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];
let normalizedUrl = pathname === "/" ? "/" : pathname.replace(/\\/$/, "");
// 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;
Copy link
Contributor

Choose a reason for hiding this comment

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

This routes === pageRoutes identity check is clever but fragile — if anyone refactors matchRoute to accept a filtered or copied array, it silently falls through to _apiRouteTrie (or vice versa). Since this is generated code and the two tries are known at module scope, consider making the trie selection explicit:

Suggested change
const trie = routes === pageRoutes ? _pageRouteTrie : _apiRouteTrie;
const trie = routes === pageRoutes ? _pageRouteTrie : _apiRouteTrie;

Actually, this is what you already have — but the fallback case is a silent wrong-answer rather than a loud error. A defensive check would be safer:

const trie = routes === pageRoutes ? _pageRouteTrie
           : routes === apiRoutes ? _apiRouteTrie
           : (() => { throw new Error('matchRoute called with unknown routes array'); })();

This is a minor robustness improvement for generated code. If a third route array is ever added, the current code would silently match against the API trie.

return _trieMatch(trie, urlParts);
}

function parseQuery(url) {
Expand Down
67 changes: 16 additions & 51 deletions packages/vinext/src/routing/app-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "." | ".." | "../.." | "..." */
Expand Down Expand Up @@ -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<AppRoute[], TrieNode<AppRoute>>();

function getOrBuildAppTrie(routes: AppRoute[]): TrieNode<AppRoute> {
let trie = appTrieCache.get(routes);
if (!trie) {
trie = buildRouteTrie(routes);
appTrieCache.set(routes, trie);
}
return trie;
}

/**
* Match a URL against App Router routes.
*/
Expand All @@ -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<string, string | string[]> | null {
const params: Record<string, string | string[]> = 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);
}
72 changes: 16 additions & 56 deletions packages/vinext/src/routing/pages-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" */
Expand Down Expand Up @@ -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<Route[], TrieNode<Route>>();

function getOrBuildTrie(routes: Route[]): TrieNode<Route> {
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.
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -240,52 +246,6 @@ async function scanApiRoutes(pagesDir: string, matcher: ValidFileMatcher): Promi
return routes;
}

function matchPattern(
urlParts: string[],
patternParts: string[],
): Record<string, string | string[]> | null {
const params: Record<string, string | string[]> = 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]").
Expand Down
Loading
Loading