Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/vinext/src/entries/app-rsc-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ function buildRouteEntries(routes: AppRoute[], imports: ImportAllocator): string
(ir) => ` {
convention: ${JSON.stringify(ir.convention)},
targetPattern: ${JSON.stringify(ir.targetPattern)},
sourceMatchPattern: ${JSON.stringify(ir.sourceMatchPattern)},
interceptLayouts: [${ir.layoutPaths.map((layoutPath) => imports.getImportVar(layoutPath)).join(", ")}],
page: ${imports.getImportVar(ir.pagePath)},
params: ${JSON.stringify(ir.params)},
Expand Down
56 changes: 55 additions & 1 deletion packages/vinext/src/routing/app-route-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,22 @@ export type InterceptingRoute = {
convention: string;
/** The URL pattern this intercepts (e.g. "/photos/:id") */
targetPattern: string;
/**
* URL pattern of the *intercepting route* — the path that owns the slot
* containing this interception marker, with route groups and `@slot`
* segments stripped. Mirrors Next.js' `interceptingRoute` from
* `extractInterceptionRouteInformation`.
*
* Used at request time to gate `findIntercept` against the Next-URL /
* interception-context header: an intercept only fires when the source
* pathname matches `^<sourceMatchPattern>(?:/.*)?$`. Without this gate
* a direct RSC fetch to the intercept target would render the modal
* instead of the underlying page.
*
* @see https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/generate-interception-routes-rewrites.ts
* @see https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/interception-routes.ts
*/
sourceMatchPattern: string;
/** Absolute path to the intercepting page component */
pagePath: string;
/** Absolute layout paths inside the intercepting route tree, outermost to innermost */
Expand Down Expand Up @@ -1777,14 +1793,18 @@ function scanForInterceptingPages(
const restOfName = entry.name.slice(interceptMatch.prefix.length);
const interceptDir = path.join(currentDir, entry.name);

// Find page files within this intercepting directory tree
// Find page files within this intercepting directory tree.
// `currentDir` is the *parent* of the marker dir — used by
// computeInterceptSourceMatchPattern to derive the intercepting-route
// URL (the path that owns the slot containing the marker).
collectInterceptingPages(
interceptDir,
interceptDir,
interceptMatch.convention,
restOfName,
routeDir,
appDir,
currentDir,
results,
matcher,
);
Expand Down Expand Up @@ -1824,6 +1844,14 @@ function collectInterceptingPages(
interceptSegment: string,
routeDir: string,
appDir: string,
/**
* Filesystem directory that owns the slot containing the interception
* marker — i.e. the parent of the marker dir. Used to derive the
* intercepting-route URL pattern that gates `findIntercept` at request
* time. With route groups and `@slot` segments stripped, this becomes
* Next.js' `interceptingRoute`.
*/
interceptParentDir: string,
results: InterceptingRoute[],
matcher: ValidFileMatcher,
parentLayoutPaths: readonly string[] = [],
Expand All @@ -1845,10 +1873,12 @@ function collectInterceptingPages(
appDir,
);
if (targetPattern) {
const sourceMatchPattern = computeInterceptSourceMatchPattern(interceptParentDir, appDir);
results.push({
convention,
layoutPaths: [...layoutPaths],
targetPattern: targetPattern.pattern,
sourceMatchPattern,
pagePath: page,
params: targetPattern.params,
});
Expand All @@ -1869,13 +1899,37 @@ function collectInterceptingPages(
interceptSegment,
routeDir,
appDir,
interceptParentDir,
results,
matcher,
layoutPaths,
);
}
}

/**
* Compute the URL pattern for the *intercepting route* — the path that
* owns the slot containing the interception marker. Route groups (`(name)`)
* and parallel slots (`@slot`) are stripped because Next.js'
* `normalizeAppPath` treats them as invisible in the URL.
*
* Mirrors Next.js' computation in `extractInterceptionRouteInformation`:
* `interceptingRoute = normalizeAppPath(path.split(marker, 2)[0])`.
*
* Returns `/` for the app root.
*
* @see https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/interception-routes.ts
*/
function computeInterceptSourceMatchPattern(interceptParentDir: string, appDir: string): string {
const segments = path.relative(appDir, interceptParentDir).split(path.sep).filter(Boolean);
const converted = convertSegmentsToRouteParts(segments);
const urlSegments = converted
? converted.urlSegments
: segments.filter((segment) => !isInvisibleSegment(segment));
if (urlSegments.length === 0) return "/";
return "/" + urlSegments.join("/");
}

/**
* Check whether a path segment is invisible in the URL (route groups, parallel slots, ".").
*
Expand Down
126 changes: 119 additions & 7 deletions packages/vinext/src/server/app-rsc-route-matching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ type AppRscRouteParams = RoutePatternParams;

type AppRscInterceptForMatching = {
targetPattern: string;
/**
* URL pattern of the *intercepting route* (the path that owns the slot,
* with route groups and `@slot` segments stripped). Mirrors Next.js'
* `interceptingRoute` from `extractInterceptionRouteInformation`.
*
* Next.js implements interception as a rewrite that fires only when the
* `Next-URL` header matches `^<sourceMatchPattern>(?:/.*)?$`. vinext's
* matcher enforces the same constraint at `findIntercept`: an intercept
* whose `targetPattern` matches the request URL is only valid when the
* provided source pathname (X-Vinext-Interception-Context / Next-URL)
* matches this pattern, with descendants allowed.
*
* Optional for backwards compat: when absent or empty, the matcher falls
* back to the legacy behavior of matching by target alone (still gated on
* a non-null source pathname).
*
* @see https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/generate-interception-routes-rewrites.ts
*/
sourceMatchPattern?: string;
interceptLayouts: readonly unknown[];
page: unknown;
params: readonly string[];
Expand All @@ -30,6 +49,8 @@ type AppRscInterceptLookupEntry = {
slotKey: string;
targetPattern: string;
targetPatternParts: string[];
sourceMatchPattern: string | null;
sourceMatchPatternParts: string[] | null;
interceptLayouts: readonly unknown[];
page: unknown;
params: readonly string[];
Expand Down Expand Up @@ -60,25 +81,110 @@ export function createAppRscRouteMatcher<Route extends AppRscRouteForMatching>(
return trieMatch(routeTrie, appRscPathnameParts(url));
},
findIntercept(pathname, sourcePathname = null) {
// Mirror Next.js' rewrite semantics: interception only fires when the
// Next-URL header is present AND matches the intercepting route's regex
// (with descendants allowed). Without a source pathname there is no
// header for the rewrite to gate on, so we render the direct route.
// https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/generate-interception-routes-rewrites.ts
if (sourcePathname === null) return null;

const urlParts = appRscPathnameParts(pathname);
const sourceParts = appRscPathnameParts(sourcePathname);

for (const entry of interceptLookup) {
// Primary gate: when the intercept declares a `sourceMatchPattern`
// (the intercepting route's path, descendants allowed), require the
// request's source pathname to satisfy it. This mirrors Next.js'
// `^<interceptingRoute>(?:/.*)?$` header regex precisely and is the
// authoritative gate when the manifest carries the pattern.
if (!matchInterceptSource(sourceParts, entry)) continue;

const params = matchAppRscRoutePattern(urlParts, entry.targetPatternParts);
if (params !== null) {
const sourceRoute = routes[entry.sourceRouteIndex];
const sourceParams = sourceRoute
? matchAppRscRoutePattern(sourceParts, sourceRoute.patternParts)
: null;
if (sourceParams === null) continue;
return { ...entry, matchedParams: mergeMatchedParams(sourceParams, params) };
if (params === null) continue;

const sourceRoute = routes[entry.sourceRouteIndex];
const matchedSourceParams = sourceRoute
? matchAppRscRoutePattern(sourceParts, sourceRoute.patternParts)
: null;

// Secondary gate (from #1249): when the entry has no
// `sourceMatchPatternParts` declared (older manifest shapes), reject
// sources that don't match the slot owner's route pattern exactly.
// This is the safety net that keeps unrelated sources from pulling
// in a modal they have no slot for. When `sourceMatchPatternParts`
// *is* declared, `matchInterceptSource` above has already approved
// the source (including descendants), so a stricter exact-match
// check on the slot-owner route here would defeat the descendant
// semantics — fall back to empty params instead.
if (matchedSourceParams === null && entry.sourceMatchPatternParts === null) {
continue;
}
const sourceParams = matchedSourceParams ?? createRouteParams();
return { ...entry, matchedParams: mergeMatchedParams(sourceParams, params) };
}
return null;
},
};
}

/**
* Check whether the request's source pathname (Next-URL / interception
* context) satisfies the intercept entry's intercepting-route pattern, with
* descendants allowed. Mirrors the header regex shape Next.js emits for the
* generated interception rewrite: `^<pattern>(?:/.*)?$`.
*
* When the entry has no declared `sourceMatchPatternParts`, fall back to the
* legacy behavior of accepting any source (we still require the source to be
* non-null at the caller — see `findIntercept`).
*/
function matchInterceptSource(sourceParts: string[], entry: AppRscInterceptLookupEntry): boolean {
const patternParts = entry.sourceMatchPatternParts;
if (!patternParts) return true;
// Root pattern (`/`) matches any source.
if (patternParts.length === 0) return true;
return matchPathPrefix(sourceParts, patternParts);
}

/**
* True when `pathParts` matches `patternParts` from index 0 and all pattern
* segments are satisfied. Trailing path segments beyond the pattern are
* allowed (to mirror Next.js' `(?:/.*)?` tail on intercepting-route headers).
*
* Supports the same segment syntax as the route trie:
* - `:name` — dynamic, exactly one segment
* - `:name+` — catch-all, 1+ segments (must be terminal in the pattern)
* - `:name*` — optional catch-all, 0+ segments (must be terminal)
* - anything else — literal segment
*/
function matchPathPrefix(pathParts: string[], patternParts: string[]): boolean {
let pi = 0;
for (let i = 0; i < patternParts.length; i++) {
const part = patternParts[i];
const isTerminal = i === patternParts.length - 1;

if (part.startsWith(":") && part.endsWith("+")) {
if (!isTerminal) return false;
// Catch-all must consume at least one remaining segment.
return pathParts.length - pi >= 1;
}
if (part.startsWith(":") && part.endsWith("*")) {
if (!isTerminal) return false;
// Optional catch-all accepts any tail (including empty).
return true;
}
if (pi >= pathParts.length) return false;
if (part.startsWith(":")) {
// Dynamic single-segment — any non-empty value matches.
pi++;
continue;
}
if (pathParts[pi] !== part) return false;
pi++;
}
// Pattern fully consumed; any extra path segments are descendants and OK.
return true;
}

function createInterceptLookup<Route extends AppRscRouteForMatching>(
routes: Route[],
): AppRscInterceptLookupEntry[] {
Expand All @@ -89,12 +195,18 @@ function createInterceptLookup<Route extends AppRscRouteForMatching>(
for (const [slotKey, slotModule] of Object.entries(route.slots)) {
if (!slotModule.intercepts) continue;
for (const intercept of slotModule.intercepts) {
const sourceMatchPattern = intercept.sourceMatchPattern ?? null;
const sourceMatchPatternParts = sourceMatchPattern
? sourceMatchPattern.split("/").filter(Boolean)
: null;
interceptLookup.push({
sourceRouteIndex: routeIndex,
slotKey,
slotId: typeof slotModule.id === "string" ? slotModule.id : null,
targetPattern: intercept.targetPattern,
targetPatternParts: intercept.targetPattern.split("/").filter(Boolean),
sourceMatchPattern,
sourceMatchPatternParts,
interceptLayouts: intercept.interceptLayouts,
page: intercept.page,
params: intercept.params,
Expand Down
Loading
Loading