mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
* test(interception): add failing sibling-interception graph tests * feat(routing): discover sibling-style interception markers * feat(routing): emit sibling interception manifest facts * feat(app-router): serialize sibling intercepts in route manifest * feat(app-router): match sibling intercepts in route matcher * feat(app-router): render sibling intercept as full page response * test: add siblingIntercepts field to AppRoute test fixtures * test(interception): add sibling interception fixtures * fix(app-router): emit null proof for sibling intercepts to bypass slot-validation * test(interception): add sibling interception e2e specs * fix(interception): correct (...)→(..)(..) marker in interception-from-root fixture * fix(interception): resolve sourceRouteIndex to slot-owner route in intercept lookup When a route inherits a @slot from an ancestor via discoverInheritedParallelSlots, the inherited slot's interceptingRoutes are copied from the ancestor. Previously, createInterceptLookup used routeIndex (the inheriting descendant's index) as sourceRouteIndex, causing resolveAppPageInterceptState to see sourceRoute === currentRoute → kind='current-route' instead of kind='source-route'. Fix: build a patternToIndex map and resolve sourceRouteIndex via the intercept's sourceMatchPattern (which names the actual slot-owner route), falling back to routeIndex only when no match is found. * fix(interception): use locale-prefixed href so middleware interception fires correctly * fix(interception): address codex review issues in sibling intercept implementation - Pass effectiveParams to resolveAppPageHead so generateMetadata/generateViewport sees the intercepted route's params instead of the source route's params - Rename SIBLING_INTERCEPT_SLOT_NAME from __page to __vinext_sibling_intercept to prevent collision with a user @__page parallel route directory - Restore middleware-rewrite fixture link without locale prefix so the interception-dynamic-segment-middleware spec exercises the intended middleware rewrite path * fix(test): update slotId assertions and restore locale-prefixed fixture link - Update test assertions from slot:__page to slot:__vinext_sibling_intercept following the rename in commit2248b8fe- Restore locale-prefixed href in interception-mw fixture so the interception-dynamic-segment-middleware E2E tests pass (reverts the accidental revert from2248b8feback to the working state from02e80c26) * fix(interception): address review findings on sibling interception PR - Restore locale-less href in interception-mw fixture and fix the underlying middleware-rewrite interception gap: add a Referer-based fallback in app-rsc-handler so interception fires when middleware rewrites the URL before the server matches it (the client-side manifest check sees the pre-rewrite target and skips setting the interception context header; the Referer carries the source page the server needs) - Apply interceptLayouts for sibling intercepts in buildPageElements so a layout.tsx under the interception marker dir wraps the intercepting page, matching Next.js segment-layout wrapping behaviour; add fixture layout and E2E assertion - Add unit test proving findOwnerRouteForDir ancestor walk attaches a sibling intercept to the nearest ancestor route when the marker's parent dir has no page.tsx (not dropped silently) * fix(interception): send current pathname as fallback interception context The client-side manifest check compares the pre-middleware target URL against declared interception patterns. When middleware rewrites the URL (e.g. /foo/p/1 → /en/foo/p/1), the pre-rewrite URL has fewer segments and doesn't match the pattern, so no X-Vinext-Interception-Context header is sent and the server cannot fire interception. Fix: when the manifest check yields no match and there is no prior intercepted navigation in browser state, fall back to sending the current page pathname as the interception context. This mirrors Next.js sending Next-URL on every RSC navigation. The server's findIntercept is double-gated on both source and target matching, so false positives cannot occur. The previousNextUrl is also populated so back/forward traversal can restore intercepted state. Revert the Referer-based server-side fallback (unreliable across environments) in favour of this explicit browser-side fix. * fix(interception): gate middleware-rewrite fallback on declared source pattern The previous fix sent window.location.pathname as fallback interception context on every unmatched navigation, which broke prefetch cache reuse: a prefetch stored with null context wouldn't be found when the navigation used a non-null context key. Refine the fallback to only fire when the current page URL matches at least one sourcePatternParts declared in the route manifest — i.e., the current page is a known interception source. This preserves prefetch cache reuse for ordinary navigations while still enabling middleware- rewrite interception for pages that declare an interception relationship. * fix(interception): support middleware-rewrite targets in fallback context and planner The previous fallback-context gate only checked whether the current page is a declared interception source; it did not verify the target URL could plausibly be a middleware-rewritten version of the declared target pattern. This meant the client sent interception context when it shouldn't (pre-middleware target had no static anchors in common with the pattern) and the planner hard-navigated when the proof target matched only after the rewrite. Three coordinated fixes: 1. Client fallback gate (app-browser-entry / app-browser-interception-context): replace the broad source-only check with matchRoutePatternWithOptionalDynamicSegments on the target side — the pre-middleware URL must still share every static anchor of the declared target pattern, in order, while allowing dynamic segments to be absent. 2. Planner interception proof (navigation-planner): findRouteManifestInterceptionForProof now accepts a proof whose target matched URL equals the pre-rewrite canonical pathname (shorter than the full pattern) when matchRoutePatternWithOptionalDynamicSegments passes. Exact pattern match still skips the targetRouteId guard; the relaxed path skips it too so the middleware-added locale segment does not invalidate an otherwise valid proof. 3. displayPathname threading (app-rsc-handler / app-rsc-entry / app-page-element-builder): the handler now passes canonicalPathname as displayPathname separately from cleanPathname. buildPageElements uses cleanPathname for all internal routing work (slot param extraction, slot overrides) and displayPathname only for render identity — keeping the browser-visible URL in RSC metadata without affecting internal matching. * fix(interception): guard siblingInterceptElement construction and document reserved slot name * fix(interception): surface missing default export on sibling intercept pages A sibling-style interception whose intercepting page module is missing its `default` export previously fell back to the source route's page component (`effectivePageModule?.default ?? PageComponent`), silently rendering a *different* page than the one requested. Resolve the effective component from the intercepting page only and extend the existing no-default-export guard to cover sibling intercepts, so a missing export surfaces the same explicit "Page has no default export" error the source/slot paths already produce. For a normal request this is identical to the previous behavior since `effectivePageModule === pageModule`. Addresses bonk review feedback on #1804. * test(app-router): guard canonical pathname commit on soft-nav config rewrites The `displayPathname = canonicalPathname` change feeds the RSC payload identity the client planner uses to commit a navigation, so it affects every rewritten route, not just interceptions. Existing coverage (nextjs-compat/hooks.test.ts) only exercises the hard-nav (SSR) path. Add an e2e that soft-navigates (client-side) to an afterFiles-rewritten route and asserts the committed URL and `usePathname()` stay canonical (pre-rewrite), with a marker check proving no full page reload occurred. Addresses bonk review feedback on #1804. * fix(interception): restore __pageLoader on slot intercept lookup entries The main merge auto-resolved incorrectly: it moved the `__pageLoader` field that #1738 added to the *slot* intercept push block onto the new *sibling* push block instead (the two `interceptLookup.push({...})` calls are textually similar). As a result slot intercept lookup entries lost `__pageLoader`, so resolveAppPageInterceptState never loaded the lazy intercepting page (`page` stayed null) and the modal slot fell back to default.tsx — breaking every slot interception. Restore `__pageLoader: intercept.__pageLoader` on the slot push block. The sibling block already carries it (see merge commit), and both intercept types declare the optional field. Fixes the app-router-dev-server intercept tests and the interception e2e suites that regressed after merging main. --------- Co-authored-by: James Anderson <james@eli.cx>
This commit is contained in:
committed by
GitHub
parent
6005541c19
commit
b4c829d6a2
@@ -494,13 +494,14 @@ function findIntercept(pathname, sourcePathname = null) {
|
||||
return __routeMatcher.findIntercept(pathname, sourcePathname);
|
||||
}
|
||||
|
||||
async function buildPageElements(route, params, routePath, pageRequest, layoutParamAccess) {
|
||||
async function buildPageElements(route, params, routePath, pageRequest, layoutParamAccess, displayPathname = routePath) {
|
||||
// Hydrate lazy page/route-handler modules before any synchronous read.
|
||||
await __ensureRouteLoaded(route);
|
||||
return __buildPageElements({
|
||||
route,
|
||||
params,
|
||||
routePath,
|
||||
displayPathname,
|
||||
pageRequest,
|
||||
globalErrorModule: ${globalErrorVar ? globalErrorVar : "null"},
|
||||
rootNotFoundModule: ${rootNotFoundVar ? rootNotFoundVar : "null"},
|
||||
@@ -578,6 +579,7 @@ export default __createAppRscHandler({
|
||||
dispatchMatchedPage({
|
||||
clientReuseManifest,
|
||||
cleanPathname,
|
||||
displayPathname,
|
||||
formState,
|
||||
actionError,
|
||||
actionFailed,
|
||||
@@ -621,7 +623,7 @@ export default __createAppRscHandler({
|
||||
request,
|
||||
mountedSlotsHeader,
|
||||
renderMode,
|
||||
}, layoutParamAccess);
|
||||
}, layoutParamAccess, displayPathname);
|
||||
},
|
||||
clientReuseManifest,
|
||||
cleanPathname,
|
||||
|
||||
@@ -194,6 +194,14 @@ function registerRouteModules(routes: AppRoute[], imports: ImportAllocator): voi
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const ir of route.siblingIntercepts ?? []) {
|
||||
// Lazy-load the intercepting page (like slot intercepts) so its CSS chunk
|
||||
// stays isolated in production (#1738). Layouts remain eager.
|
||||
imports.getLazyLoaderVar(ir.pagePath);
|
||||
for (const layoutPath of ir.layoutPaths) {
|
||||
imports.getImportVar(layoutPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +224,18 @@ function buildRouteEntries(routes: AppRoute[], imports: ImportAllocator): string
|
||||
const unauthorizedVars = (route.unauthorizedPaths ?? []).map((up) =>
|
||||
up ? imports.getImportVar(up) : "null",
|
||||
);
|
||||
const siblingInterceptEntries = (route.siblingIntercepts ?? []).map(
|
||||
(ir) => ` {
|
||||
convention: ${JSON.stringify(ir.convention)},
|
||||
targetPattern: ${JSON.stringify(ir.targetPattern)},
|
||||
sourceMatchPattern: ${JSON.stringify(ir.sourceMatchPattern)},
|
||||
slotId: ${JSON.stringify(ir.slotId ?? null)},
|
||||
interceptLayouts: [${ir.layoutPaths.map((l) => imports.getImportVar(l)).join(", ")}],
|
||||
page: null,
|
||||
__pageLoader: ${imports.getLazyLoaderVar(ir.pagePath)},
|
||||
params: ${JSON.stringify(ir.params)},
|
||||
}`,
|
||||
);
|
||||
const slotEntries = route.parallelSlots.map((slot) => {
|
||||
const interceptEntries = slot.interceptingRoutes.map(
|
||||
(ir) => ` {
|
||||
@@ -280,6 +300,9 @@ ${interceptEntries.join(",\n")}
|
||||
slots: {
|
||||
${slotEntries.join(",\n")}
|
||||
},
|
||||
siblingIntercepts: [
|
||||
${siblingInterceptEntries.join(",\n")}
|
||||
],
|
||||
loading: ${route.loadingPath ? imports.getImportVar(route.loadingPath) : "null"},
|
||||
error: ${route.errorPath ? imports.getImportVar(route.errorPath) : "null"},
|
||||
notFound: ${route.notFoundPath ? imports.getImportVar(route.notFoundPath) : "null"},
|
||||
|
||||
@@ -39,6 +39,11 @@ type InterceptingRoute = {
|
||||
layoutPaths: string[];
|
||||
/** Parameter names for dynamic segments */
|
||||
params: string[];
|
||||
/**
|
||||
* Synthetic page-carrier slot id for sibling (slot-less) interception.
|
||||
* Set only when the marker has no `@slot` wrapper; undefined for slot intercepts.
|
||||
*/
|
||||
slotId?: string;
|
||||
};
|
||||
|
||||
type ParallelSlot = {
|
||||
@@ -110,6 +115,12 @@ export type AppRoute = {
|
||||
templates: string[];
|
||||
/** Parallel route slots (from @slot directories at the route's directory level) */
|
||||
parallelSlots: ParallelSlot[];
|
||||
/**
|
||||
* Interception markers not wrapped in an `@slot` directory.
|
||||
* On soft-nav, the intercepting page replaces the entire page response.
|
||||
* Empty array when there are no sibling-style interception markers.
|
||||
*/
|
||||
siblingIntercepts: InterceptingRoute[];
|
||||
/** Loading component path */
|
||||
loadingPath: string | null;
|
||||
/** Error component path (leaf directory only) */
|
||||
@@ -356,6 +367,13 @@ function createAppRouteGraphDefaultId(slotId: string): string {
|
||||
return `default:${slotId}`;
|
||||
}
|
||||
|
||||
// "__vinext_"-prefixed names are reserved; user-defined parallel routes can
|
||||
// never be named @__vinext_sibling_intercept, making slot-id collisions impossible.
|
||||
const SIBLING_INTERCEPT_SLOT_NAME = "__vinext_sibling_intercept";
|
||||
function createAppRouteGraphSiblingInterceptSlotId(sourcePattern: string): string {
|
||||
return createAppRouteGraphSlotId(SIBLING_INTERCEPT_SLOT_NAME, sourcePattern);
|
||||
}
|
||||
|
||||
function createAppRouteGraphInterceptionId(
|
||||
slotId: string,
|
||||
sourcePattern: string,
|
||||
@@ -553,6 +571,28 @@ function createStaticSegmentGraph(routes: readonly AppRouteGraphRoute[]): Static
|
||||
slot,
|
||||
});
|
||||
}
|
||||
|
||||
// Emit sibling interception facts (markers without an @slot wrapper).
|
||||
// The synthetic slotId is stored on each InterceptingRoute.
|
||||
for (const ir of route.siblingIntercepts) {
|
||||
if (!ir.slotId) continue;
|
||||
const id = createAppRouteGraphInterceptionId(
|
||||
ir.slotId,
|
||||
ir.sourceMatchPattern,
|
||||
ir.targetPattern,
|
||||
);
|
||||
interceptions.set(id, {
|
||||
id,
|
||||
sourcePattern: ir.sourceMatchPattern,
|
||||
sourcePatternParts: splitRouteManifestPatternParts(ir.sourceMatchPattern),
|
||||
targetPattern: ir.targetPattern,
|
||||
targetPatternParts: splitRouteManifestPatternParts(ir.targetPattern),
|
||||
slotId: ir.slotId,
|
||||
ownerLayoutId: null,
|
||||
interceptingRouteId: routeIdByPattern.get(ir.sourceMatchPattern) ?? null,
|
||||
targetRouteId: routeIdByPattern.get(ir.targetPattern) ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const interceptionsBySlotId = createRouteManifestInterceptionsBySlotId(interceptions);
|
||||
@@ -899,15 +939,19 @@ export async function buildAppRouteGraph(
|
||||
const slotSubRoutes = discoverSlotSubRoutes(routes, matcher, ghostParentRoutes);
|
||||
routes.push(...slotSubRoutes);
|
||||
|
||||
// Discover sibling-style interception markers (markers not inside an @slot directory).
|
||||
discoverSiblingInterceptingRoutes(routes, appDir, matcher);
|
||||
|
||||
validatePageRouteConflicts(routes, appDir);
|
||||
validateRoutePatterns(routes.map((route) => route.pattern));
|
||||
const interceptTargetPatterns = [
|
||||
...new Set(
|
||||
routes.flatMap((route) =>
|
||||
route.parallelSlots.flatMap((slot) =>
|
||||
routes.flatMap((route) => [
|
||||
...route.parallelSlots.flatMap((slot) =>
|
||||
slot.interceptingRoutes.map((intercept) => intercept.targetPattern),
|
||||
),
|
||||
),
|
||||
...route.siblingIntercepts.map((intercept) => intercept.targetPattern),
|
||||
]),
|
||||
),
|
||||
];
|
||||
validateRoutePatterns(interceptTargetPatterns);
|
||||
@@ -1169,6 +1213,7 @@ function discoverSlotSubRoutes(
|
||||
params: [...parentRoute.params, ...subParams],
|
||||
rootParamNames: parentRoute.rootParamNames,
|
||||
patternParts: [...parentRoute.patternParts, ...urlParts],
|
||||
siblingIntercepts: [],
|
||||
};
|
||||
syntheticRoutes.push(newRoute);
|
||||
routesByPattern.set(pattern, newRoute);
|
||||
@@ -1361,6 +1406,7 @@ function directoryToAppRoute(
|
||||
params,
|
||||
rootParamNames: computeRootParamNames(segments, layoutTreePositions),
|
||||
patternParts: urlSegments,
|
||||
siblingIntercepts: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1983,6 +2029,137 @@ function discoverInterceptingRoutes(
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover sibling-style interception markers — interception marker directories
|
||||
* (e.g. `(..)showcase`, `(..)(..)hoge`) that are NOT wrapped inside an `@slot`
|
||||
* directory. Mutates each matching route's `siblingIntercepts` array.
|
||||
*
|
||||
* Sibling intercepts use the same conventions and target-computation logic as
|
||||
* slot intercepts, but their intercepting page replaces the full page response
|
||||
* (not a slot) during soft navigation.
|
||||
*/
|
||||
function discoverSiblingInterceptingRoutes(
|
||||
routes: AppRouteGraphRoute[],
|
||||
appDir: string,
|
||||
matcher: ValidFileMatcher,
|
||||
): void {
|
||||
// Build a map from a route's "owner directory" to the routes it serves.
|
||||
// A route's owner directory is derived from its pagePath or its routePath.
|
||||
// Multiple routes may share a directory (e.g. catch-all + static page),
|
||||
// so we map dir → first matched route (any route in the directory will do).
|
||||
const routesByDir = new Map<string, AppRouteGraphRoute>();
|
||||
for (const route of routes) {
|
||||
const filePath = route.pagePath ?? route.routePath;
|
||||
if (!filePath) continue;
|
||||
const routeDir = path.dirname(filePath);
|
||||
if (!routesByDir.has(routeDir)) {
|
||||
routesByDir.set(routeDir, route);
|
||||
}
|
||||
}
|
||||
|
||||
function walk(dir: string): void {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
// Skip private folders (_private convention)
|
||||
if (entry.name.startsWith("_")) continue;
|
||||
// Skip @slot subtrees — their markers are handled by the slot path
|
||||
if (entry.name.startsWith("@")) continue;
|
||||
|
||||
const childDir = path.join(dir, entry.name);
|
||||
const marker = matchInterceptConvention(entry.name);
|
||||
|
||||
if (marker) {
|
||||
// This is a sibling interception marker directory (no @slot wrapper).
|
||||
// Collect all intercept targets from the marker subtree.
|
||||
const restOfName = entry.name.slice(marker.prefix.length);
|
||||
const parentDir = dir; // directory that owns the marker (the "intercepting route" dir)
|
||||
const results: InterceptingRoute[] = [];
|
||||
collectInterceptingPages(
|
||||
childDir,
|
||||
childDir,
|
||||
marker.convention,
|
||||
restOfName,
|
||||
parentDir, // routeDir: the parent directory (no @slot between parent and marker)
|
||||
appDir,
|
||||
parentDir, // interceptParentDir: same as routeDir for sibling case
|
||||
results,
|
||||
matcher,
|
||||
);
|
||||
for (const ir of results) {
|
||||
ir.slotId = createAppRouteGraphSiblingInterceptSlotId(ir.sourceMatchPattern);
|
||||
// Find the route that serves the parentDir. Fall back to scanning all
|
||||
// routes that live under parentDir (handles the case where the route
|
||||
// pattern is a catch-all like /templates/:catchAll+ rather than /templates).
|
||||
const owner = findOwnerRouteForDir(parentDir, appDir, routes, routesByDir);
|
||||
if (owner) {
|
||||
owner.siblingIntercepts.push(ir);
|
||||
}
|
||||
}
|
||||
// collectInterceptingPages already scanned the marker subtree; skip walk into it
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular directory — keep walking for nested markers
|
||||
walk(childDir);
|
||||
}
|
||||
}
|
||||
|
||||
walk(appDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best route to attach a sibling intercept to, given the directory
|
||||
* that contains the interception marker.
|
||||
*
|
||||
* 1. Exact hit: a route whose page/handler lives directly in `dir`.
|
||||
* 2. Subtree hit: shallowest route whose page lives anywhere under `dir`
|
||||
* (handles catch-all routes like `/templates/:catchAll+`).
|
||||
* 3. Ancestor walk: walk up the directory tree toward `appDir` looking for
|
||||
* any of the above. This handles the case where the marker directory has
|
||||
* no sibling pages at all (e.g. `deep/path/(...)target` with no
|
||||
* `deep/path/page.tsx`).
|
||||
*/
|
||||
function findOwnerRouteForDir(
|
||||
dir: string,
|
||||
appDir: string,
|
||||
routes: readonly AppRouteGraphRoute[],
|
||||
routesByDir: Map<string, AppRouteGraphRoute>,
|
||||
): AppRouteGraphRoute | null {
|
||||
let current = dir;
|
||||
while (true) {
|
||||
// Exact match: a route whose page/handler file lives directly in `current`
|
||||
const exact = routesByDir.get(current);
|
||||
if (exact) return exact;
|
||||
|
||||
// Subtree match: a route whose page is somewhere under `current` — pick
|
||||
// the one with the fewest pattern parts (shallowest / least specific).
|
||||
const currentWithSep = current + path.sep;
|
||||
let best: AppRouteGraphRoute | null = null;
|
||||
for (const route of routes) {
|
||||
const filePath = route.pagePath ?? route.routePath;
|
||||
if (!filePath) continue;
|
||||
if (!filePath.startsWith(currentWithSep)) continue;
|
||||
if (!best || route.patternParts.length < best.patternParts.length) {
|
||||
best = route;
|
||||
}
|
||||
}
|
||||
if (best) return best;
|
||||
|
||||
// Stop if we've reached the app root
|
||||
if (current === appDir) break;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break; // filesystem root safety guard
|
||||
current = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scan a directory tree for page.tsx files that are inside
|
||||
* intercepting route directories.
|
||||
|
||||
@@ -169,6 +169,41 @@ export function matchRoutePatternPrefix(
|
||||
return true;
|
||||
}
|
||||
|
||||
export function matchRoutePatternWithOptionalDynamicSegments(
|
||||
pathParts: readonly string[],
|
||||
patternParts: readonly string[],
|
||||
): boolean {
|
||||
function matchFrom(pathIndex: number, patternIndex: number): boolean {
|
||||
if (patternIndex === patternParts.length) {
|
||||
return pathIndex === pathParts.length;
|
||||
}
|
||||
|
||||
const patternPart = patternParts[patternIndex];
|
||||
const isCatchAll =
|
||||
patternPart.startsWith(":") && (patternPart.endsWith("+") || patternPart.endsWith("*"));
|
||||
|
||||
if (isCatchAll) {
|
||||
const minLength = patternPart.endsWith("+") ? 1 : 0;
|
||||
for (let endIndex = pathIndex + minLength; endIndex <= pathParts.length; endIndex++) {
|
||||
if (matchFrom(endIndex, patternIndex + 1)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (patternPart.startsWith(":")) {
|
||||
return (
|
||||
matchFrom(pathIndex, patternIndex + 1) ||
|
||||
(pathIndex < pathParts.length && matchFrom(pathIndex + 1, patternIndex + 1))
|
||||
);
|
||||
}
|
||||
|
||||
if (pathIndex >= pathParts.length || pathParts[pathIndex] !== patternPart) return false;
|
||||
return matchFrom(pathIndex + 1, patternIndex + 1);
|
||||
}
|
||||
|
||||
return matchFrom(0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single entry from `getStaticPaths().paths`.
|
||||
*
|
||||
|
||||
@@ -79,7 +79,10 @@ import {
|
||||
type PendingBrowserRouterState,
|
||||
} from "./app-browser-navigation-controller.js";
|
||||
import { AppBrowserMpaNavigationScheduler } from "./app-browser-mpa-navigation.js";
|
||||
import { resolveManifestNavigationInterceptionContext } from "./app-browser-interception-context.js";
|
||||
import {
|
||||
resolveManifestNavigationInterceptionContext,
|
||||
resolveMiddlewareRewriteNavigationInterceptionContext,
|
||||
} from "./app-browser-interception-context.js";
|
||||
import {
|
||||
createDiscardedServerActionRefreshScheduler,
|
||||
createServerActionInitiationSnapshot,
|
||||
@@ -987,6 +990,32 @@ function getRequestState(
|
||||
previousNextUrl: window.location.pathname + window.location.search,
|
||||
};
|
||||
}
|
||||
// Fallback: when the current page is a declared interception source and
|
||||
// the target URL still matches the declared target prefix, send the
|
||||
// current pathname as context so the server can fire interception for
|
||||
// middleware-rewritten targets. The client manifest check above only
|
||||
// matches the pre-middleware target URL against the declared pattern;
|
||||
// when middleware adds a segment (e.g. locale prefix), the pre-rewrite
|
||||
// URL is shorter than the pattern and the match fails. Sending the
|
||||
// current pathname lets the server re-check after applying the rewrite.
|
||||
//
|
||||
// We gate on source plus target prefix rather than always sending
|
||||
// context, to preserve prefetch cache reuse for ordinary navigations
|
||||
// where interception cannot apply.
|
||||
const middlewareRewriteInterceptionContext =
|
||||
resolveMiddlewareRewriteNavigationInterceptionContext({
|
||||
basePath: __basePath,
|
||||
currentPathname: window.location.pathname,
|
||||
routeManifest: getBrowserRouteManifest(),
|
||||
targetPathname,
|
||||
});
|
||||
if (middlewareRewriteInterceptionContext !== null) {
|
||||
const currentHrefForFallback = window.location.pathname + window.location.search;
|
||||
return {
|
||||
interceptionContext: middlewareRewriteInterceptionContext,
|
||||
previousNextUrl: currentHrefForFallback,
|
||||
};
|
||||
}
|
||||
return {
|
||||
interceptionContext: null,
|
||||
previousNextUrl: null,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { RouteManifest } from "../routing/app-route-graph.js";
|
||||
import { matchRoutePattern, matchRoutePatternPrefix } from "../routing/route-pattern.js";
|
||||
import {
|
||||
matchRoutePattern,
|
||||
matchRoutePatternPrefix,
|
||||
matchRoutePatternWithOptionalDynamicSegments,
|
||||
} from "../routing/route-pattern.js";
|
||||
import { splitPathnameForRouteMatch } from "../routing/utils.js";
|
||||
import { stripBasePath } from "../utils/base-path.js";
|
||||
|
||||
@@ -39,3 +43,26 @@ export function resolveManifestNavigationInterceptionContext(
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveMiddlewareRewriteNavigationInterceptionContext(
|
||||
options: ResolveManifestNavigationInterceptionContextOptions,
|
||||
): string | null {
|
||||
if (options.routeManifest === null) return null;
|
||||
|
||||
const currentPathname = stripBasePath(options.currentPathname, options.basePath);
|
||||
const targetPathname = stripBasePath(options.targetPathname, options.basePath);
|
||||
const sourceParts = splitPathnameForRouteMatch(currentPathname);
|
||||
const targetParts = splitPathnameForRouteMatch(targetPathname);
|
||||
|
||||
for (const interception of options.routeManifest.segmentGraph.interceptions.values()) {
|
||||
if (!matchRoutePatternPrefix(sourceParts, interception.sourcePatternParts)) continue;
|
||||
if (
|
||||
!matchRoutePatternWithOptionalDynamicSegments(targetParts, interception.targetPatternParts)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
return currentPathname;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createElement } from "react";
|
||||
import { makeThenableParams } from "vinext/shims/thenable-params";
|
||||
import { resolveActiveParallelRouteHeadInputs, resolveAppPageHead } from "./app-page-head.js";
|
||||
import { SIBLING_PAGE_INTERCEPT_SLOT_KEY } from "./app-rsc-route-matching.js";
|
||||
import {
|
||||
buildAppPageElements,
|
||||
createAppPageTreePath,
|
||||
@@ -73,6 +74,7 @@ export type BuildPageElementsOptions<
|
||||
route: AppPageBuildRoute<TModule, TErrorModule>;
|
||||
params: AppPageParams;
|
||||
routePath: string;
|
||||
displayPathname?: string;
|
||||
pageRequest: AppPagePageRequest<TModule>;
|
||||
/** Root-level global-error.tsx module. Present when the app defines this file. */
|
||||
globalErrorModule?: TErrorModule | null;
|
||||
@@ -118,6 +120,7 @@ export async function buildPageElements<
|
||||
route,
|
||||
params,
|
||||
routePath,
|
||||
displayPathname = routePath,
|
||||
pageRequest,
|
||||
globalErrorModule,
|
||||
rootNotFoundModule,
|
||||
@@ -134,16 +137,43 @@ export async function buildPageElements<
|
||||
} = pageRequest;
|
||||
|
||||
const pageModule: AppPageModule | null | undefined = route.page;
|
||||
const PageComponent = pageModule?.default;
|
||||
|
||||
// Sibling intercepts replace the full page — the intercepting page is the
|
||||
// effective page module. Slot-based intercepts use a different code path
|
||||
// (buildSlotOverrides) and are unaffected.
|
||||
const isSiblingIntercept =
|
||||
opts?.interceptSlotKey === SIBLING_PAGE_INTERCEPT_SLOT_KEY && !!opts?.interceptPage;
|
||||
const effectivePageModule = isSiblingIntercept
|
||||
? (opts!.interceptPage as AppPageModule | null | undefined)
|
||||
: pageModule;
|
||||
// Resolve the component that will actually render. For a sibling intercept
|
||||
// this is the intercepting page's own default export — we deliberately do
|
||||
// NOT fall back to the source route's page component. Silently rendering a
|
||||
// *different* page than the requested intercept is a surprising failure mode;
|
||||
// a missing default export is surfaced as an explicit error below, mirroring
|
||||
// the source/slot no-export handling. For a normal request this is identical
|
||||
// to `pageModule?.default` since `effectivePageModule === pageModule`.
|
||||
const EffectivePageComponent = effectivePageModule?.default;
|
||||
const effectiveParams = isSiblingIntercept ? (opts!.interceptParams ?? params) : params;
|
||||
|
||||
const hasPageModule = !!pageModule;
|
||||
const renderIdentity = createAppPageRenderIdentity({
|
||||
displayPathname: routePath,
|
||||
displayPathname,
|
||||
interceptionContext: opts?.interceptionContext ?? null,
|
||||
interceptSourceMatchedUrl: opts?.interceptSourceMatchedUrl ?? null,
|
||||
interceptSlotId: opts?.interceptSlotId ?? null,
|
||||
// Sibling intercepts are full-page replacements with no slot proof.
|
||||
// Passing null here makes the payload carry interception:null so the
|
||||
// client planner commits the result as a normal navigation rather than
|
||||
// attempting slot-preservation validation (which would fail — the
|
||||
// synthetic __page slot has no real slot binding in the component tree).
|
||||
interceptSlotId: isSiblingIntercept ? null : (opts?.interceptSlotId ?? null),
|
||||
});
|
||||
|
||||
if (hasPageModule && !PageComponent) {
|
||||
// Surface a clear "no default export" error for whichever page will render:
|
||||
// the source route page on a normal request, or the intercepting page for a
|
||||
// sibling intercept. Without the `isSiblingIntercept` arm, an intercepting
|
||||
// page missing its default export would silently render the source page.
|
||||
if ((hasPageModule || isSiblingIntercept) && !EffectivePageComponent) {
|
||||
let noExportRootLayout: string | null = null;
|
||||
const noExportLayoutIds =
|
||||
route.ids?.layouts ??
|
||||
@@ -178,7 +208,7 @@ export async function buildPageElements<
|
||||
layoutModules: route.layouts,
|
||||
layoutTreePositions: route.layoutTreePositions,
|
||||
metadataRoutes,
|
||||
pageModule: route.page ?? null,
|
||||
pageModule: effectivePageModule ?? null,
|
||||
parallelRoutes: resolveActiveParallelRouteHeadInputs({
|
||||
interceptLayouts: opts?.interceptLayouts ?? null,
|
||||
interceptPage: opts?.interceptPage ?? null,
|
||||
@@ -188,13 +218,13 @@ export async function buildPageElements<
|
||||
routeSegments: route.routeSegments ?? [],
|
||||
slots: route.slots ?? null,
|
||||
}),
|
||||
params,
|
||||
params: effectiveParams,
|
||||
routePath: route.pattern,
|
||||
routeSegments: route.routeSegments ?? null,
|
||||
searchParams,
|
||||
});
|
||||
|
||||
const pageProps: Record<string, unknown> = { params: makeThenableParams(params) };
|
||||
const pageProps: Record<string, unknown> = { params: makeThenableParams(effectiveParams) };
|
||||
let pageSearchParamsThenable: unknown;
|
||||
if (searchParams) {
|
||||
const shouldObservePageSearchParamsAccess =
|
||||
@@ -217,8 +247,41 @@ export async function buildPageElements<
|
||||
? "body"
|
||||
: "head";
|
||||
|
||||
// For sibling intercepts, wrap the intercepting page in any layouts that
|
||||
// live under the interception marker directory (interceptLayouts). In Next.js
|
||||
// the intercepting route's segment layouts wrap the intercepting page; the
|
||||
// slot-based path handles this inside buildSlotOverrides/app-page-route-wiring,
|
||||
// but sibling intercepts bypass that path entirely. We apply the wrapping here
|
||||
// so a layout.tsx adjacent to the (.) / (..) / (...) marker dir is respected.
|
||||
let siblingInterceptElement: ReturnType<typeof createElement> | null =
|
||||
isSiblingIntercept && EffectivePageComponent
|
||||
? createElement(EffectivePageComponent, pageProps)
|
||||
: null;
|
||||
if (isSiblingIntercept && siblingInterceptElement !== null && opts?.interceptLayouts?.length) {
|
||||
const siblingThenableParams = makeThenableParams(effectiveParams);
|
||||
for (let i = opts.interceptLayouts.length - 1; i >= 0; i--) {
|
||||
const layoutMod = opts.interceptLayouts[i] as AppPageModule | null | undefined;
|
||||
const LayoutComponent = layoutMod?.default;
|
||||
if (LayoutComponent) {
|
||||
// Layout component types vary; cast to any to avoid overload-resolution
|
||||
// issues in createElement while preserving runtime safety.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const LC = LayoutComponent as (props: any) => any;
|
||||
siblingInterceptElement = createElement(
|
||||
LC,
|
||||
{ params: siblingThenableParams },
|
||||
siblingInterceptElement,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buildAppPageElements({
|
||||
element: PageComponent ? createElement(PageComponent, pageProps) : null,
|
||||
element: isSiblingIntercept
|
||||
? siblingInterceptElement
|
||||
: EffectivePageComponent
|
||||
? createElement(EffectivePageComponent, pageProps)
|
||||
: null,
|
||||
// Fall back to vinext's built-in default global error module so that
|
||||
// uncaught client render errors are caught by the route-level
|
||||
// <ErrorBoundary> wrapper in app-page-route-wiring.tsx, mirroring
|
||||
@@ -268,7 +331,12 @@ function buildSlotOverrides<TModule extends AppPageModule, TErrorModule extends
|
||||
): Readonly<Record<string, AppPageSlotOverride<TModule>>> | null {
|
||||
const overrides: Record<string, AppPageSlotOverride<TModule>> = {};
|
||||
|
||||
if (opts && opts.interceptSlotKey && opts.interceptPage) {
|
||||
if (
|
||||
opts &&
|
||||
opts.interceptSlotKey &&
|
||||
opts.interceptPage &&
|
||||
opts.interceptSlotKey !== SIBLING_PAGE_INTERCEPT_SLOT_KEY
|
||||
) {
|
||||
overrides[opts.interceptSlotKey] = {
|
||||
layoutModules: opts.interceptLayouts || null,
|
||||
pageModule: opts.interceptPage,
|
||||
|
||||
@@ -404,17 +404,20 @@ export async function resolveAppPageIntercept<TRoute, TPage, TInterceptOpts, TEl
|
||||
});
|
||||
|
||||
if (interceptState.kind === "source-route") {
|
||||
const renderRoute = interceptState.sourceRoute;
|
||||
const renderParams = pickRouteParams(
|
||||
interceptState.intercept.matchedParams,
|
||||
options.getRouteParamNames(interceptState.sourceRoute),
|
||||
);
|
||||
|
||||
options.setNavigationContext({
|
||||
params: interceptState.intercept.matchedParams,
|
||||
pathname: options.cleanPathname,
|
||||
searchParams: options.searchParams,
|
||||
});
|
||||
const interceptElement = await options.buildPageElement(
|
||||
interceptState.sourceRoute,
|
||||
pickRouteParams(
|
||||
interceptState.intercept.matchedParams,
|
||||
options.getRouteParamNames(interceptState.sourceRoute),
|
||||
),
|
||||
renderRoute,
|
||||
renderParams,
|
||||
options.toInterceptOpts(interceptState.intercept),
|
||||
options.searchParams,
|
||||
options.layoutParamAccess,
|
||||
@@ -422,7 +425,7 @@ export async function resolveAppPageIntercept<TRoute, TPage, TInterceptOpts, TEl
|
||||
|
||||
return {
|
||||
interceptOpts: undefined,
|
||||
response: await options.renderInterceptResponse(interceptState.sourceRoute, interceptElement),
|
||||
response: await options.renderInterceptResponse(renderRoute, interceptElement),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ function applyMiddlewareContextToResponse(
|
||||
type DispatchMatchedPageOptions<TRoute> = {
|
||||
clientReuseManifest: ClientReuseManifestParseResult;
|
||||
cleanPathname: string;
|
||||
displayPathname: string;
|
||||
formState: ReactFormState | null;
|
||||
actionError?: unknown;
|
||||
actionFailed?: boolean;
|
||||
@@ -748,6 +749,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
|
||||
const pageResponse = await options.dispatchMatchedPage({
|
||||
clientReuseManifest,
|
||||
cleanPathname,
|
||||
displayPathname: canonicalPathname,
|
||||
formState,
|
||||
actionError,
|
||||
actionFailed,
|
||||
|
||||
@@ -6,6 +6,13 @@ import {
|
||||
} from "../routing/route-pattern.js";
|
||||
import { splitPathnameForRouteMatch } from "../routing/utils.js";
|
||||
|
||||
/**
|
||||
* Sentinel slot key used for sibling-style interception entries.
|
||||
* When a matched intercept carries this key, the render layer replaces the
|
||||
* route's main page element instead of a parallel slot.
|
||||
*/
|
||||
export const SIBLING_PAGE_INTERCEPT_SLOT_KEY = "__vinext_page_intercept";
|
||||
|
||||
type AppRscRouteParams = RoutePatternParams;
|
||||
|
||||
type AppRscInterceptForMatching = {
|
||||
@@ -40,9 +47,25 @@ type AppRscSlotForMatching = {
|
||||
intercepts?: readonly AppRscInterceptForMatching[];
|
||||
};
|
||||
|
||||
type AppRscSiblingInterceptForMatching = {
|
||||
targetPattern: string;
|
||||
sourceMatchPattern: string | null;
|
||||
slotId: string | null;
|
||||
interceptLayouts: readonly unknown[];
|
||||
page: unknown;
|
||||
// Sibling intercept pages are lazy-loaded (manifest emits `page: null` plus a
|
||||
// `__pageLoader`) so the intercepting page's CSS chunk stays isolated in
|
||||
// production, matching slot intercepts (see #1738). The loader is awaited on
|
||||
// demand by resolveAppPageInterceptState / probePage.
|
||||
__pageLoader?: (() => Promise<unknown>) | null;
|
||||
params: readonly string[];
|
||||
};
|
||||
|
||||
type AppRscRouteForMatching = {
|
||||
pattern: string;
|
||||
patternParts: string[];
|
||||
slots?: Record<string, AppRscSlotForMatching>;
|
||||
siblingIntercepts?: AppRscSiblingInterceptForMatching[];
|
||||
};
|
||||
|
||||
type AppRscInterceptMatch = AppRscInterceptLookupEntry & {
|
||||
@@ -154,21 +177,60 @@ function matchInterceptSource(sourceParts: string[], entry: AppRscInterceptLooku
|
||||
function createInterceptLookup<Route extends AppRscRouteForMatching>(
|
||||
routes: Route[],
|
||||
): AppRscInterceptLookupEntry[] {
|
||||
// Build a pattern→index map so slot intercepts resolve to the actual owner
|
||||
// route rather than the inheriting descendant that carries the slot copy.
|
||||
// When a route inherits a @slot from an ancestor (e.g. /groups/:id/new
|
||||
// inheriting @modal from /interception-dyn-single), the inherited slot's
|
||||
// interceptingRoutes include a sourceMatchPattern that names the real owner
|
||||
// ("/interception-dyn-single"). Using that pattern's index as sourceRouteIndex
|
||||
// ensures resolveAppPageInterceptState produces kind="source-route" (owner ≠
|
||||
// current) rather than kind="current-route" (owner === current), which would
|
||||
// render the descendant page instead of the owner's layout+page tree.
|
||||
const patternToIndex = new Map<string, number>(routes.map((r, i) => [r.pattern, i]));
|
||||
|
||||
const interceptLookup: AppRscInterceptLookupEntry[] = [];
|
||||
for (let routeIndex = 0; routeIndex < routes.length; routeIndex++) {
|
||||
const route = routes[routeIndex];
|
||||
if (!route.slots) continue;
|
||||
for (const [slotKey, slotModule] of Object.entries(route.slots)) {
|
||||
if (!slotModule.intercepts) continue;
|
||||
for (const intercept of slotModule.intercepts) {
|
||||
if (route.slots) {
|
||||
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;
|
||||
// Prefer the route whose pattern matches sourceMatchPattern (the actual
|
||||
// slot-owner route). Fall back to routeIndex when no match is found.
|
||||
const ownerRouteIndex =
|
||||
sourceMatchPattern !== null
|
||||
? (patternToIndex.get(sourceMatchPattern) ?? routeIndex)
|
||||
: routeIndex;
|
||||
interceptLookup.push({
|
||||
sourceRouteIndex: ownerRouteIndex,
|
||||
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,
|
||||
__pageLoader: intercept.__pageLoader,
|
||||
params: intercept.params,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (route.siblingIntercepts) {
|
||||
for (const intercept of route.siblingIntercepts) {
|
||||
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,
|
||||
slotKey: SIBLING_PAGE_INTERCEPT_SLOT_KEY,
|
||||
slotId: typeof intercept.slotId === "string" ? intercept.slotId : null,
|
||||
targetPattern: intercept.targetPattern,
|
||||
targetPatternParts: intercept.targetPattern.split("/").filter(Boolean),
|
||||
sourceMatchPattern,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { matchRoutePattern, matchRoutePatternPrefix } from "../routing/route-pattern.js";
|
||||
import {
|
||||
matchRoutePattern,
|
||||
matchRoutePatternPrefix,
|
||||
matchRoutePatternWithOptionalDynamicSegments,
|
||||
} from "../routing/route-pattern.js";
|
||||
import { splitPathnameForRouteMatch } from "../routing/utils.js";
|
||||
import type {
|
||||
RouteManifest,
|
||||
@@ -385,8 +389,18 @@ function findRouteManifestInterceptionForProof(
|
||||
if (!matchRoutePatternPrefix(sourceParts, interception.sourcePatternParts)) {
|
||||
continue;
|
||||
}
|
||||
if (matchRoutePattern(targetParts, interception.targetPatternParts) === null) continue;
|
||||
if (interception.targetRouteId !== null && targetRoute?.id !== interception.targetRouteId) {
|
||||
const exactTargetParams = matchRoutePattern(targetParts, interception.targetPatternParts);
|
||||
const allowsMiddlewareRewriteTarget =
|
||||
exactTargetParams === null &&
|
||||
matchRoutePatternWithOptionalDynamicSegments(targetParts, interception.targetPatternParts);
|
||||
if (exactTargetParams === null && !allowsMiddlewareRewriteTarget) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!allowsMiddlewareRewriteTarget &&
|
||||
interception.targetRouteId !== null &&
|
||||
targetRoute?.id !== interception.targetRouteId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
return interception;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
import { resolveManifestNavigationInterceptionContext } from "../packages/vinext/src/server/app-browser-interception-context.js";
|
||||
import {
|
||||
resolveManifestNavigationInterceptionContext,
|
||||
resolveMiddlewareRewriteNavigationInterceptionContext,
|
||||
} from "../packages/vinext/src/server/app-browser-interception-context.js";
|
||||
import type {
|
||||
RouteManifest,
|
||||
RouteManifestInterception,
|
||||
@@ -37,6 +40,18 @@ const feedPhotoInterception: RouteManifestInterception = {
|
||||
targetRouteId: "route:/photos/:id",
|
||||
};
|
||||
|
||||
const localePhotoInterception: RouteManifestInterception = {
|
||||
id: "interception:slot:modal:/interception-mw/:locale->/interception-mw/:locale/:username/p/:id",
|
||||
interceptingRouteId: "route:/interception-mw/:locale",
|
||||
ownerLayoutId: "layout:/interception-mw/:locale",
|
||||
slotId: "slot:modal:/interception-mw/:locale",
|
||||
sourcePattern: "/interception-mw/:locale",
|
||||
sourcePatternParts: ["interception-mw", ":locale"],
|
||||
targetPattern: "/interception-mw/:locale/:username/p/:id",
|
||||
targetPatternParts: ["interception-mw", ":locale", ":username", "p", ":id"],
|
||||
targetRouteId: "route:/interception-mw/:locale/:username/p/:id",
|
||||
};
|
||||
|
||||
describe("resolveManifestNavigationInterceptionContext", () => {
|
||||
it("uses manifest-declared interception rules for first-hop browser navigations", () => {
|
||||
expect(
|
||||
@@ -80,3 +95,36 @@ describe("resolveManifestNavigationInterceptionContext", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMiddlewareRewriteNavigationInterceptionContext", () => {
|
||||
it("uses manifest source and target prefix rules for middleware-rewritten first-hop navigations", () => {
|
||||
expect(
|
||||
resolveMiddlewareRewriteNavigationInterceptionContext({
|
||||
basePath: "",
|
||||
currentPathname: "/interception-mw/en",
|
||||
routeManifest: createRouteManifest([localePhotoInterception]),
|
||||
targetPathname: "/interception-mw/foo/p/1",
|
||||
}),
|
||||
).toBe("/interception-mw/en");
|
||||
});
|
||||
|
||||
it("does not infer fallback context when the target cannot be an intercepted route", () => {
|
||||
expect(
|
||||
resolveMiddlewareRewriteNavigationInterceptionContext({
|
||||
basePath: "",
|
||||
currentPathname: "/feed",
|
||||
routeManifest: createRouteManifest([feedPhotoInterception]),
|
||||
targetPathname: "/about",
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
resolveMiddlewareRewriteNavigationInterceptionContext({
|
||||
basePath: "",
|
||||
currentPathname: "/interception-mw/en",
|
||||
routeManifest: createRouteManifest([localePhotoInterception]),
|
||||
targetPathname: "/x/interception-mw/foo/p/1",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import { readStreamAsText } from "../packages/vinext/src/utils/text-stream.js";
|
||||
import { buildPageElements } from "../packages/vinext/src/server/app-page-element-builder.js";
|
||||
import type { AppPageBuildRoute } from "../packages/vinext/src/server/app-page-element-builder.js";
|
||||
import { probeAppPage } from "../packages/vinext/src/server/app-page-probe.js";
|
||||
import { SIBLING_PAGE_INTERCEPT_SLOT_KEY } from "../packages/vinext/src/server/app-rsc-route-matching.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
@@ -209,6 +210,45 @@ describe("buildPageElements", () => {
|
||||
expect(record["route:/test"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("surfaces a no-default-export error for a sibling intercept page instead of rendering the source page", async () => {
|
||||
function SourcePage(): React.ReactNode {
|
||||
return React.createElement("div", null, "Source page content");
|
||||
}
|
||||
|
||||
const route = createSyntheticRoute({
|
||||
// The source route has a valid default export, so the dispatch-level
|
||||
// no-export guard passes and the request reaches buildPageElements.
|
||||
page: createSyntheticPageModule(SourcePage),
|
||||
layouts: [],
|
||||
routeSegments: ["photo", "[id]"],
|
||||
pattern: "/photo/[id]",
|
||||
});
|
||||
|
||||
const result = await buildPageElements(
|
||||
createBaseOptions({
|
||||
route,
|
||||
routePath: "/photo/42",
|
||||
opts: {
|
||||
interceptSlotKey: SIBLING_PAGE_INTERCEPT_SLOT_KEY,
|
||||
// The intercepting page module is missing its `default` export.
|
||||
interceptPage: createSyntheticPageModuleWithoutDefault(),
|
||||
interceptParams: { id: "42" },
|
||||
} as Record<string, unknown>,
|
||||
}),
|
||||
);
|
||||
|
||||
const record = result as Record<string, unknown>;
|
||||
const routeKey = record[APP_ROUTE_KEY] as string;
|
||||
const errorElement = record[routeKey];
|
||||
expect(React.isValidElement(errorElement)).toBe(true);
|
||||
|
||||
const html = await renderNode(errorElement as React.ReactNode);
|
||||
// The error is surfaced explicitly rather than silently falling back to
|
||||
// the source route's page component.
|
||||
expect(html).toContain("Page has no default export");
|
||||
expect(html).not.toContain("Source page content");
|
||||
});
|
||||
|
||||
it("keeps interception context out of the error payload route ID", async () => {
|
||||
const route = createSyntheticRoute({
|
||||
page: createSyntheticPageModuleWithoutDefault(),
|
||||
|
||||
+130
-19
@@ -1062,6 +1062,28 @@ describe("App Router route graph builder", () => {
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectSiblingIntercepts(routes: readonly AppRouteGraphRoute[]) {
|
||||
const out: Array<{
|
||||
ownerRoute: string;
|
||||
targetPattern: string;
|
||||
sourceMatchPattern: string;
|
||||
convention: string;
|
||||
params: string[];
|
||||
}> = [];
|
||||
for (const route of routes) {
|
||||
for (const ir of (route as any).siblingIntercepts ?? []) {
|
||||
out.push({
|
||||
ownerRoute: route.pattern,
|
||||
targetPattern: ir.targetPattern,
|
||||
sourceMatchPattern: ir.sourceMatchPattern,
|
||||
convention: ir.convention,
|
||||
params: ir.params,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
it("computes `/` for root-level (.) slot", async () => {
|
||||
// Mirrors test/e2e/app-dir/parallel-routes-and-interception-basepath.
|
||||
await withTempApp(async (appDir) => {
|
||||
@@ -1256,13 +1278,10 @@ describe("App Router route graph builder", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores interception marker directories that live outside a parallel slot", async () => {
|
||||
it("registers `(..)` sibling interception for showcase catchall outside a parallel slot", async () => {
|
||||
// Ported from Next.js: test/e2e/app-dir/interception-routes-multiple-catchall
|
||||
// and test/e2e/app-dir/interception-segments-two-levels-above. Next.js
|
||||
// allows interception marker directories anywhere — they should not be
|
||||
// treated as standalone routes (the marker is not a real URL segment),
|
||||
// so the build must not register `/templates/(..)showcase` as a page
|
||||
// and must not throw while validating its pattern.
|
||||
// The marker at templates/(..)showcase is a sibling (no @slot). Build must not
|
||||
// register it as a literal route, AND must register it as a sibling intercept.
|
||||
await withTempApp(async (appDir) => {
|
||||
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
|
||||
await writeAppFile(appDir, "page.tsx", EMPTY_PAGE);
|
||||
@@ -1275,19 +1294,32 @@ describe("App Router route graph builder", () => {
|
||||
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
|
||||
const patterns = graph.routes.map((route) => route.pattern);
|
||||
|
||||
// The marker directory itself is not a route — it must never surface
|
||||
// as `/templates/(..)showcase` or similar.
|
||||
for (const pattern of patterns) {
|
||||
expect(pattern).not.toMatch(/\(\.{1,3}\)/);
|
||||
}
|
||||
|
||||
const intercepts = collectSiblingIntercepts(graph.routes);
|
||||
// (..) from templates/ climbs 1 visible segment → target /showcase
|
||||
expect(intercepts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
targetPattern: "/showcase",
|
||||
sourceMatchPattern: "/templates",
|
||||
convention: "..",
|
||||
}),
|
||||
);
|
||||
// Also the catchAll page registers a sibling intercept for /showcase/:catchAll+
|
||||
expect(intercepts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
targetPattern: "/showcase/:catchAll+",
|
||||
sourceMatchPattern: "/templates",
|
||||
convention: "..",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores `(..)(..)` interception marker outside a parallel slot", async () => {
|
||||
it("registers `(..)(..)` sibling interception outside a parallel slot", async () => {
|
||||
// Ported from Next.js: test/e2e/app-dir/interception-segments-two-levels-above
|
||||
// app/foo/bar/(..)(..)hoge/page.tsx is a sibling-style interception
|
||||
// marker (no @slot). Build must not throw and must not register a
|
||||
// route with the literal marker in its pattern.
|
||||
await withTempApp(async (appDir) => {
|
||||
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
|
||||
await writeAppFile(appDir, "page.tsx", EMPTY_PAGE);
|
||||
@@ -1301,12 +1333,19 @@ describe("App Router route graph builder", () => {
|
||||
for (const pattern of patterns) {
|
||||
expect(pattern).not.toMatch(/\(\.{1,3}\)/);
|
||||
}
|
||||
|
||||
const intercepts = collectSiblingIntercepts(graph.routes);
|
||||
expect(intercepts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
targetPattern: "/hoge",
|
||||
sourceMatchPattern: "/foo/bar",
|
||||
convention: "../..",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores `(.)` same-level interception marker outside a parallel slot", async () => {
|
||||
// Coverage for the same-level marker — sibling of a regular route,
|
||||
// not inside a `@slot`. Must not register `/gallery/(.)photo` as a page.
|
||||
it("registers `(.)` sibling interception outside a parallel slot", async () => {
|
||||
await withTempApp(async (appDir) => {
|
||||
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
|
||||
await writeAppFile(appDir, "page.tsx", EMPTY_PAGE);
|
||||
@@ -1319,13 +1358,20 @@ describe("App Router route graph builder", () => {
|
||||
for (const pattern of patterns) {
|
||||
expect(pattern).not.toMatch(/\(\.{1,3}\)/);
|
||||
}
|
||||
|
||||
const intercepts = collectSiblingIntercepts(graph.routes);
|
||||
// (.) resolves relative to the marker's parent dir (gallery/), so target = /gallery/photo
|
||||
expect(intercepts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
targetPattern: "/gallery/photo",
|
||||
sourceMatchPattern: "/gallery",
|
||||
convention: ".",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores `(...)` root interception marker outside a parallel slot", async () => {
|
||||
// Coverage for the root marker — `(...)` always resolves against the
|
||||
// app root, so the marker must be stripped even when buried deep in
|
||||
// the filesystem tree.
|
||||
it("registers `(...)` sibling root interception outside a parallel slot", async () => {
|
||||
await withTempApp(async (appDir) => {
|
||||
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
|
||||
await writeAppFile(appDir, "page.tsx", EMPTY_PAGE);
|
||||
@@ -1338,6 +1384,71 @@ describe("App Router route graph builder", () => {
|
||||
for (const pattern of patterns) {
|
||||
expect(pattern).not.toMatch(/\(\.{1,3}\)/);
|
||||
}
|
||||
|
||||
const intercepts = collectSiblingIntercepts(graph.routes);
|
||||
expect(intercepts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
targetPattern: "/target",
|
||||
sourceMatchPattern: "/deep/path",
|
||||
convention: "...",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches sibling intercept to ancestor route when parent dir has no page.tsx", async () => {
|
||||
// When the marker's immediate parent dir has no page, findOwnerRouteForDir must
|
||||
// walk up to the nearest ancestor that has a route.
|
||||
// Structure: deep/path/(...)target/page.tsx with NO deep/path/page.tsx.
|
||||
// The intercept should attach to the root route ("/") via ancestor walk.
|
||||
await withTempApp(async (appDir) => {
|
||||
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
|
||||
await writeAppFile(appDir, "page.tsx", EMPTY_PAGE);
|
||||
await writeAppFile(appDir, "target/page.tsx", EMPTY_PAGE);
|
||||
// Note: no deep/page.tsx or deep/path/page.tsx — both intermediate dirs are empty
|
||||
await writeAppFile(appDir, "deep/path/(...)target/page.tsx", EMPTY_PAGE);
|
||||
|
||||
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
|
||||
|
||||
const intercepts = collectSiblingIntercepts(graph.routes);
|
||||
// Must not be dropped — must attach to some route via ancestor walk
|
||||
expect(intercepts.length).toBeGreaterThan(0);
|
||||
const intercept = intercepts.find((ir) => ir.targetPattern === "/target");
|
||||
expect(intercept).toBeDefined();
|
||||
// The nearest ancestor route with a page is "/" (the root)
|
||||
expect(intercept?.ownerRoute).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
it("promotes sibling interception into RouteManifest facts", async () => {
|
||||
// Sibling intercepts (no @slot) must appear in routeManifest.segmentGraph.interceptions
|
||||
// and be accessible via interceptionsBySlotId using the synthetic slot id.
|
||||
await withTempApp(async (appDir) => {
|
||||
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
|
||||
await writeAppFile(appDir, "page.tsx", EMPTY_PAGE);
|
||||
await writeAppFile(appDir, "hoge/page.tsx", EMPTY_PAGE);
|
||||
await writeAppFile(appDir, "foo/bar/page.tsx", EMPTY_PAGE);
|
||||
await writeAppFile(appDir, "foo/bar/(..)(..)hoge/page.tsx", EMPTY_PAGE);
|
||||
|
||||
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
|
||||
const facts = Array.from(graph.routeManifest.segmentGraph.interceptions.values());
|
||||
|
||||
expect(facts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
sourcePattern: "/foo/bar",
|
||||
targetPattern: "/hoge",
|
||||
slotId: "slot:__vinext_sibling_intercept:/foo/bar",
|
||||
}),
|
||||
);
|
||||
|
||||
const bySlotId = graph.routeManifest.segmentGraph.interceptionsBySlotId.get(
|
||||
"slot:__vinext_sibling_intercept:/foo/bar",
|
||||
);
|
||||
expect(bySlotId).toHaveLength(1);
|
||||
expect(bySlotId![0]).toMatchObject({
|
||||
sourcePattern: "/foo/bar",
|
||||
targetPattern: "/hoge",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,6 +131,7 @@ describe("generateRscEntry ISR code generation", () => {
|
||||
templateTreePositions: [],
|
||||
unauthorizedPaths: [],
|
||||
unauthorizedPath: null,
|
||||
siblingIntercepts: [],
|
||||
};
|
||||
|
||||
const code = generateRscEntry("/tmp/test/app", [routeWithInterceptLayouts]);
|
||||
@@ -166,6 +167,7 @@ describe("generateRscEntry ISR code generation", () => {
|
||||
templateTreePositions: [],
|
||||
unauthorizedPaths: [],
|
||||
unauthorizedPath: null,
|
||||
siblingIntercepts: [],
|
||||
};
|
||||
|
||||
const code = generateRscEntry("/tmp/test/app", [routeWithRootParams]);
|
||||
|
||||
@@ -28,6 +28,7 @@ describe("RSC Flight hint fix", () => {
|
||||
isDynamic: false,
|
||||
params: [],
|
||||
patternParts: ["/"],
|
||||
siblingIntercepts: [],
|
||||
};
|
||||
const code = generateRscEntry("/tmp/test/app", [route]);
|
||||
expect(code).toContain("_renderToReadableStream");
|
||||
|
||||
@@ -92,6 +92,7 @@ describe("App Router Static export", () => {
|
||||
isDynamic: true,
|
||||
params: ["id"],
|
||||
patternParts: ["fake", ":id"],
|
||||
siblingIntercepts: [],
|
||||
},
|
||||
];
|
||||
const config = await resolveNextConfig({ output: "export" });
|
||||
@@ -139,6 +140,7 @@ describe("App Router Static export", () => {
|
||||
isDynamic: false,
|
||||
params: [],
|
||||
patternParts: ["api", "test"],
|
||||
siblingIntercepts: [],
|
||||
},
|
||||
];
|
||||
const config = await resolveNextConfig({ output: "export" });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
|
||||
import {
|
||||
createAppRscRouteMatcher,
|
||||
matchAppRscRoutePattern,
|
||||
SIBLING_PAGE_INTERCEPT_SLOT_KEY,
|
||||
} from "../packages/vinext/src/server/app-rsc-route-matching.js";
|
||||
|
||||
describe("App RSC route matching", () => {
|
||||
@@ -312,6 +313,38 @@ describe("App RSC route matching", () => {
|
||||
expect(matcher.findIntercept("/groups/123/new", null)).toBeNull();
|
||||
});
|
||||
|
||||
it("findIntercept matches sibling intercept on soft-nav and misses on hard-nav", () => {
|
||||
const routes: TestRoute[] = [
|
||||
{
|
||||
pattern: "/foo/bar",
|
||||
patternParts: ["foo", "bar"],
|
||||
siblingIntercepts: [
|
||||
{
|
||||
targetPattern: "/hoge",
|
||||
sourceMatchPattern: "/foo/bar",
|
||||
slotId: "slot:__vinext_sibling_intercept:/foo/bar",
|
||||
interceptLayouts: [],
|
||||
page: { default: () => null },
|
||||
params: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ pattern: "/hoge", patternParts: ["hoge"] },
|
||||
];
|
||||
const matcher = createAppRscRouteMatcher(routes as any);
|
||||
|
||||
// Soft-nav from /foo/bar: should match
|
||||
const hit = matcher.findIntercept("/hoge", "/foo/bar");
|
||||
expect(hit).not.toBeNull();
|
||||
expect(hit?.slotKey).toBe(SIBLING_PAGE_INTERCEPT_SLOT_KEY);
|
||||
|
||||
// Hard-nav (no source): must return null
|
||||
expect(matcher.findIntercept("/hoge", null)).toBeNull();
|
||||
|
||||
// Wrong source: must return null
|
||||
expect(matcher.findIntercept("/hoge", "/other")).toBeNull();
|
||||
});
|
||||
|
||||
it("matches dynamic segments in the intercepting route pattern", () => {
|
||||
// /[lang]/foo/(..)photos has interceptingRoute `/[lang]/foo`,
|
||||
// header regex `^/(?<lang>[^/]+)/foo(?:/.*)?$`.
|
||||
@@ -353,10 +386,20 @@ function route(
|
||||
};
|
||||
}
|
||||
|
||||
type TestSiblingIntercept = {
|
||||
targetPattern: string;
|
||||
sourceMatchPattern: string | null;
|
||||
slotId: string | null;
|
||||
interceptLayouts: readonly unknown[];
|
||||
page: unknown;
|
||||
params: string[];
|
||||
};
|
||||
|
||||
type TestRoute = {
|
||||
pattern: string;
|
||||
patternParts: string[];
|
||||
slots?: Record<string, { intercepts?: TestIntercept[] }>;
|
||||
siblingIntercepts?: TestSiblingIntercept[];
|
||||
};
|
||||
|
||||
type TestIntercept = {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* Tests: ON-12, ON-15 in TRACKING.md
|
||||
*/
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { waitForAppRouterHydration } from "../helpers";
|
||||
|
||||
const BASE = "http://localhost:4174";
|
||||
|
||||
@@ -118,6 +119,46 @@ test.describe("Config Rewrites (OpenNext compat)", () => {
|
||||
const el = page.getByText("Welcome to App Router", { exact: true });
|
||||
await expect(el).toBeVisible();
|
||||
});
|
||||
|
||||
// Soft (client-side) navigation to an afterFiles-rewritten route must commit
|
||||
// the CANONICAL (pre-rewrite) URL — both in the address bar and in
|
||||
// usePathname(). The rewrite /rewritten-use-pathname → /nextjs-compat/hooks-search
|
||||
// serves the search page's content, but the committed pathname stays canonical.
|
||||
//
|
||||
// This guards the cross-cutting `displayPathname = canonicalPathname` change in
|
||||
// app-rsc-handler.ts: displayPathname feeds the RSC payload identity the client
|
||||
// planner uses to commit a navigation, so a regression here would mis-commit the
|
||||
// internal rewrite target for *every* rewritten route, not just interceptions.
|
||||
// The existing nextjs-compat/hooks.test.ts only covers the hard-nav (SSR) path.
|
||||
// Ref: Next.js test/e2e/app-dir/hooks — "should have the canonical url pathname on rewrite"
|
||||
test("soft-nav to a config-rewritten route commits the canonical URL and usePathname", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(BASE);
|
||||
await waitForAppRouterHydration(page);
|
||||
|
||||
// Marker survives a soft navigation but is wiped by a full page reload.
|
||||
await page.evaluate(() => {
|
||||
(window as { __REWRITE_SOFT_NAV_MARKER__?: string }).__REWRITE_SOFT_NAV_MARKER__ = "alive";
|
||||
});
|
||||
|
||||
await page.click('[data-testid="config-rewrite-pathname-link"]');
|
||||
|
||||
// Address bar shows the canonical (pre-rewrite) URL, not the internal target.
|
||||
await page.waitForURL(/\/rewritten-use-pathname$/);
|
||||
|
||||
// The rewrite target's content rendered (from /nextjs-compat/hooks-search).
|
||||
await expect(page.locator("#search-test-page")).toBeVisible();
|
||||
|
||||
// usePathname() reflects the canonical URL, not the internal rewrite target.
|
||||
await expect(page.locator("#current-pathname")).toHaveText("/rewritten-use-pathname");
|
||||
|
||||
// No full page reload occurred — this was a genuine client-side commit.
|
||||
const marker = await page.evaluate(
|
||||
() => (window as { __REWRITE_SOFT_NAV_MARKER__?: string }).__REWRITE_SOFT_NAV_MARKER__,
|
||||
);
|
||||
expect(marker).toBe("alive");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Config Custom Headers (OpenNext compat)", () => {
|
||||
|
||||
@@ -11,8 +11,6 @@ const LOCALE_HOME = `${BASE}/interception-mw/en`;
|
||||
|
||||
test.describe("interception-dynamic-segment-middleware", () => {
|
||||
test("intercepts dynamic route when middleware rewrites add locale prefix", async ({ page }) => {
|
||||
// TODO(#1364 Part C): interception doesn't fire when middleware rewrites add a locale prefix.
|
||||
test.fail();
|
||||
await page.goto(LOCALE_HOME);
|
||||
await waitForAppRouterHydration(page);
|
||||
|
||||
@@ -24,8 +22,6 @@ test.describe("interception-dynamic-segment-middleware", () => {
|
||||
});
|
||||
|
||||
test("refresh after interception shows non-intercepted page", async ({ page }) => {
|
||||
// TODO(#1364 Part C): depends on interception firing (see test above).
|
||||
test.fail();
|
||||
await page.goto(LOCALE_HOME);
|
||||
await waitForAppRouterHydration(page);
|
||||
|
||||
@@ -42,8 +38,6 @@ test.describe("interception-dynamic-segment-middleware", () => {
|
||||
test("back/forward navigation preserves intercepted state with middleware active", async ({
|
||||
page,
|
||||
}) => {
|
||||
// TODO(#1364 Part C): depends on interception firing (see test above).
|
||||
test.fail();
|
||||
await page.goto(LOCALE_HOME);
|
||||
await waitForAppRouterHydration(page);
|
||||
|
||||
@@ -58,8 +52,6 @@ test.describe("interception-dynamic-segment-middleware", () => {
|
||||
});
|
||||
|
||||
test("repeated interceptions with middleware work consistently", async ({ page }) => {
|
||||
// TODO(#1364 Part C): depends on interception firing (see test above).
|
||||
test.fail();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await page.goto(LOCALE_HOME);
|
||||
await waitForAppRouterHydration(page);
|
||||
|
||||
@@ -9,9 +9,6 @@ const GROUPS_123 = `${BASE}/interception-dyn-single/groups/123`;
|
||||
|
||||
test.describe("interception-dynamic-single-segment", () => {
|
||||
test("intercepts /groups/[id]/new with (.) from /groups/[id]", async ({ page }) => {
|
||||
// TODO(#1364 Part C): source page is replaced instead of preserved in #children slot.
|
||||
// The modal fires but #children shows the new-item page instead of the group page.
|
||||
test.fail();
|
||||
// The (.) modifier matches same-level routes. The bug was that
|
||||
// [^/]+ only matched single segments, failing when the source had
|
||||
// multiple path segments like /groups/123. Fixed by using .+ (any depth).
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Ported from Next.js: test/e2e/app-dir/interception-routes-multiple-catchall/interception-routes-multiple-catchall.test.ts
|
||||
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/interception-routes-multiple-catchall/interception-routes-multiple-catchall.test.ts
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { waitForAppRouterHydration } from "../helpers";
|
||||
|
||||
const BASE = "http://localhost:4174";
|
||||
const TEMPLATES_MULTI = `${BASE}/interception-routes-multiple-catchall/templates/multi/slug`;
|
||||
|
||||
test.describe("interception-routes-multiple-catchall", () => {
|
||||
test("soft-nav from templates to showcase shows intercepting page", async ({ page }) => {
|
||||
await page.goto(TEMPLATES_MULTI);
|
||||
await waitForAppRouterHydration(page);
|
||||
|
||||
await page.click("#to-showcase-catchall");
|
||||
|
||||
await expect(page.locator("#intercepting-page")).toBeVisible();
|
||||
await expect(page.locator("#root-catchall")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("soft-nav to showcase/single from templates shows intercepting page", async ({ page }) => {
|
||||
await page.goto(TEMPLATES_MULTI);
|
||||
await waitForAppRouterHydration(page);
|
||||
|
||||
await page.click("#to-showcase-single");
|
||||
|
||||
await expect(page.locator("#intercepting-page")).toBeVisible();
|
||||
});
|
||||
|
||||
test("soft-nav to showcase/another/slug from templates shows intercepting page", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(TEMPLATES_MULTI);
|
||||
await waitForAppRouterHydration(page);
|
||||
|
||||
await page.click("#to-showcase-another");
|
||||
|
||||
await expect(page.locator("#intercepting-page")).toBeVisible();
|
||||
});
|
||||
|
||||
test("hard-nav to showcase URL shows root catch-all (no interception)", async ({ page }) => {
|
||||
await page.goto(`${BASE}/interception-routes-multiple-catchall/showcase/new`);
|
||||
|
||||
await expect(page.locator("#root-catchall")).toBeVisible();
|
||||
await expect(page.locator("#intercepting-page")).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// Ported from Next.js: test/e2e/app-dir/interception-segments-two-levels-above/interception-segments-two-levels-above.test.ts
|
||||
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/interception-segments-two-levels-above/interception-segments-two-levels-above.test.ts
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { waitForAppRouterHydration } from "../helpers";
|
||||
|
||||
const BASE = "http://localhost:4174";
|
||||
const FOO_BAR = `${BASE}/interception-segments-two-levels-above/foo/bar`;
|
||||
const HOGE = `${BASE}/interception-segments-two-levels-above/hoge`;
|
||||
|
||||
test.describe("interception-segments-two-levels-above", () => {
|
||||
test("intercepts /hoge with (..)(..) from /foo/bar on soft-nav", async ({ page }) => {
|
||||
await page.goto(FOO_BAR);
|
||||
await waitForAppRouterHydration(page);
|
||||
|
||||
await page.click("#link-hoge");
|
||||
|
||||
await expect(page.locator("#intercepted")).toBeVisible();
|
||||
await expect(page.locator("#hoge")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("hard-nav to /hoge shows real target page (no interception)", async ({ page }) => {
|
||||
await page.goto(HOGE);
|
||||
|
||||
await expect(page.locator("#hoge")).toBeVisible();
|
||||
await expect(page.locator("#intercepted")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("back navigation after interception returns to /foo/bar", async ({ page }) => {
|
||||
await page.goto(FOO_BAR);
|
||||
await waitForAppRouterHydration(page);
|
||||
|
||||
await page.click("#link-hoge");
|
||||
await expect(page.locator("#intercepted")).toBeVisible();
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(FOO_BAR);
|
||||
await expect(page.locator("#foo-bar-page")).toBeVisible();
|
||||
});
|
||||
|
||||
test("forward navigation after back restores intercepted view", async ({ page }) => {
|
||||
await page.goto(FOO_BAR);
|
||||
await waitForAppRouterHydration(page);
|
||||
|
||||
await page.click("#link-hoge");
|
||||
await expect(page.locator("#intercepted")).toBeVisible();
|
||||
|
||||
await page.goBack();
|
||||
await page.goForward();
|
||||
|
||||
await expect(page.locator("#intercepted")).toBeVisible();
|
||||
});
|
||||
|
||||
test("repeated interceptions work consistently", async ({ page }) => {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await page.goto(FOO_BAR);
|
||||
await waitForAppRouterHydration(page);
|
||||
|
||||
await page.click("#link-hoge");
|
||||
await expect(page.locator("#intercepted")).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("layout.tsx under the interception marker wraps the intercepted page", async ({ page }) => {
|
||||
await page.goto(FOO_BAR);
|
||||
await waitForAppRouterHydration(page);
|
||||
|
||||
await page.click("#link-hoge");
|
||||
await expect(page.locator("#intercepted")).toBeVisible();
|
||||
// The layout.tsx inside (..)(..)hoge/ must wrap the intercepting page
|
||||
await expect(page.locator("#intercept-layout-wrapper")).toBeVisible();
|
||||
await expect(page.locator("#intercept-layout-wrapper #intercepted")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -9,9 +9,7 @@ const EXAMPLE = `${BASE}/interception-from-root/en/example`;
|
||||
|
||||
test.describe("parallel-routes-and-interception-from-root", () => {
|
||||
test("(...)[[locale]] interceptor interpolates [locale] correctly", async ({ page }) => {
|
||||
// TODO(#1364 Part C): (...) root interception doesn't fire; navigates to the full page instead of modal.
|
||||
test.fail();
|
||||
// Tests that the (...)[locale]/intercepted pattern matches the locale
|
||||
// Tests that the (..)(..) [locale]/intercepted pattern matches the locale
|
||||
// segment dynamically. The interception lives at:
|
||||
// [locale]/example/@modal/(...)[locale]/intercepted
|
||||
// which should intercept navigation to /en/intercepted from anywhere.
|
||||
@@ -40,8 +38,6 @@ test.describe("parallel-routes-and-interception-from-root", () => {
|
||||
});
|
||||
|
||||
test("back navigation after interception returns to example page", async ({ page }) => {
|
||||
// TODO(#1364 Part C): depends on (...) interception firing (see test above).
|
||||
test.fail();
|
||||
await page.goto(EXAMPLE);
|
||||
await waitForAppRouterHydration(page);
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ const minimalAppRoutes: AppRoute[] = [
|
||||
layoutTreePositions: [0],
|
||||
isDynamic: false,
|
||||
params: [],
|
||||
siblingIntercepts: [],
|
||||
},
|
||||
{
|
||||
pattern: "/about",
|
||||
@@ -67,6 +68,7 @@ const minimalAppRoutes: AppRoute[] = [
|
||||
layoutTreePositions: [0],
|
||||
isDynamic: false,
|
||||
params: [],
|
||||
siblingIntercepts: [],
|
||||
},
|
||||
{
|
||||
pattern: "/blog/:slug",
|
||||
@@ -90,6 +92,7 @@ const minimalAppRoutes: AppRoute[] = [
|
||||
layoutTreePositions: [0, 1],
|
||||
isDynamic: true,
|
||||
params: ["slug"],
|
||||
siblingIntercepts: [],
|
||||
},
|
||||
{
|
||||
pattern: "/dashboard",
|
||||
@@ -113,6 +116,7 @@ const minimalAppRoutes: AppRoute[] = [
|
||||
layoutTreePositions: [0, 1],
|
||||
isDynamic: false,
|
||||
params: [],
|
||||
siblingIntercepts: [],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -144,6 +148,7 @@ describe("App Router generated manifest construction", () => {
|
||||
layoutTreePositions: [0, 1],
|
||||
isDynamic: false,
|
||||
params: [],
|
||||
siblingIntercepts: [],
|
||||
},
|
||||
{
|
||||
pattern: "/docs/:slug",
|
||||
@@ -167,6 +172,7 @@ describe("App Router generated manifest construction", () => {
|
||||
layoutTreePositions: [0],
|
||||
isDynamic: true,
|
||||
params: ["slug"],
|
||||
siblingIntercepts: [],
|
||||
},
|
||||
{
|
||||
pattern: "/api",
|
||||
@@ -190,6 +196,7 @@ describe("App Router generated manifest construction", () => {
|
||||
layoutTreePositions: [],
|
||||
isDynamic: false,
|
||||
params: [],
|
||||
siblingIntercepts: [],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -264,6 +271,7 @@ describe("App Router generated manifest construction", () => {
|
||||
layoutTreePositions: [0],
|
||||
isDynamic: false,
|
||||
params: [],
|
||||
siblingIntercepts: [],
|
||||
},
|
||||
{
|
||||
ids: {
|
||||
@@ -325,6 +333,7 @@ describe("App Router generated manifest construction", () => {
|
||||
isDynamic: true,
|
||||
params: ["id"],
|
||||
rootParamNames: ["id"],
|
||||
siblingIntercepts: [],
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
|
||||
@@ -403,6 +412,7 @@ describe("App Router generated manifest construction", () => {
|
||||
layoutTreePositions: [0],
|
||||
isDynamic: false,
|
||||
params: [],
|
||||
siblingIntercepts: [],
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
|
||||
@@ -447,6 +457,7 @@ describe("App Router generated manifest construction", () => {
|
||||
isDynamic: true,
|
||||
params: ["lang", "locale", "slug"],
|
||||
rootParamNames: ["lang", "locale"],
|
||||
siblingIntercepts: [],
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
|
||||
@@ -491,6 +502,7 @@ describe("App Router generated manifest construction", () => {
|
||||
isDynamic: true,
|
||||
params: ["lang", "section", "slug"],
|
||||
rootParamNames: ["lang", "section"],
|
||||
siblingIntercepts: [],
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ export default function Page() {
|
||||
return (
|
||||
<div>
|
||||
{/* Link without locale — middleware rewrites /interception-mw/foo/p/1
|
||||
to /interception-mw/en/foo/p/1 so interception fires */}
|
||||
to /interception-mw/en/foo/p/1, then the Referer-based interception
|
||||
fallback fires so the modal slot shows the intercepted page. */}
|
||||
<Link href="/interception-mw/foo/p/1" id="link-foo-p-1">
|
||||
Foo
|
||||
</Link>
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export default async function Page({ params }: { params: Promise<{ catchAll: string[] }> }) {
|
||||
const { catchAll } = await params;
|
||||
return <div id="root-catchall">Showcase Simple Page: {catchAll.join("/")}</div>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<div id="children">{children}</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Link from "next/link";
|
||||
const BASE = "/interception-routes-multiple-catchall";
|
||||
export default function Page() {
|
||||
return (
|
||||
<div>
|
||||
<Link href={`${BASE}/templates/multi/slug`} id="to-templates-multi">
|
||||
To templates/multi/slug
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export default async function Page({ params }: { params: Promise<{ catchAll: string[] }> }) {
|
||||
const { catchAll } = await params;
|
||||
return <div id="intercepting-page">intercepting: {catchAll.join("/")}</div>;
|
||||
}
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
import Link from "next/link";
|
||||
const BASE = "/interception-routes-multiple-catchall";
|
||||
export default async function Page({ params }: { params: Promise<{ catchAll: string[] }> }) {
|
||||
const { catchAll } = await params;
|
||||
return (
|
||||
<div>
|
||||
<div id="templates-catchall">templates/{catchAll.join("/")}</div>
|
||||
<Link href={`${BASE}/showcase/${catchAll.join("/")}`} id="to-showcase-catchall">
|
||||
to showcase/{catchAll.join("/")}
|
||||
</Link>
|
||||
<Link href={`${BASE}/showcase/single`} id="to-showcase-single">
|
||||
to showcase/single
|
||||
</Link>
|
||||
<Link href={`${BASE}/showcase/another/slug`} id="to-showcase-another">
|
||||
to showcase/another/slug
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import Link from "next/link";
|
||||
const BASE = "/interception-routes-multiple-catchall";
|
||||
export default function Page() {
|
||||
return (
|
||||
<div>
|
||||
<div id="templates-page">templates page</div>
|
||||
<Link href={`${BASE}/showcase/new`} id="to-showcase-new">
|
||||
to showcase/new
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
export default function InterceptLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div id="intercept-layout-wrapper">{children}</div>;
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div id="intercepted">intercepted</div>;
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import Link from "next/link";
|
||||
const BASE = "/interception-segments-two-levels-above";
|
||||
export default function Page() {
|
||||
return (
|
||||
<div>
|
||||
<div id="foo-bar-page">foo/bar page</div>
|
||||
<Link href={`${BASE}/hoge`} id="link-hoge">
|
||||
to hoge
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div id="hoge">hoge target</div>;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<div id="children">{children}</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Link from "next/link";
|
||||
const BASE = "/interception-segments-two-levels-above";
|
||||
export default function Page() {
|
||||
return (
|
||||
<div>
|
||||
<Link href={`${BASE}/foo/bar`} id="go-foo-bar">
|
||||
Go to foo/bar
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+3
@@ -18,6 +18,9 @@ export default function HomePage() {
|
||||
<Link href="/redirect-test-config" data-testid="redirect-test-link">
|
||||
Go to Redirect Test
|
||||
</Link>
|
||||
<Link href="/rewritten-use-pathname" data-testid="config-rewrite-pathname-link">
|
||||
Config Rewrite Pathname
|
||||
</Link>
|
||||
<Link
|
||||
href="/rsc-fetch-redirect-src"
|
||||
prefetch={false}
|
||||
|
||||
@@ -1356,6 +1356,7 @@ function mockRoute(pattern: string, opts: { pagePath?: string | null } = {}): Ap
|
||||
.filter((p) => p.startsWith(":"))
|
||||
.map((p) => p.replace(/^:/, "").replace(/[+*]$/, "")),
|
||||
patternParts: parts,
|
||||
siblingIntercepts: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ type MinimalAppRoute = {
|
||||
layouts: string[];
|
||||
templates: string[];
|
||||
parallelSlots: [];
|
||||
siblingIntercepts: [];
|
||||
loadingPath: null;
|
||||
errorPath: null;
|
||||
layoutErrorPaths: (string | null)[];
|
||||
@@ -48,6 +49,7 @@ function makeRoute(partial: Partial<MinimalAppRoute> & { layouts: string[] }): M
|
||||
routePath: null,
|
||||
templates: [],
|
||||
parallelSlots: [],
|
||||
siblingIntercepts: [],
|
||||
loadingPath: null,
|
||||
errorPath: null,
|
||||
layoutErrorPaths: partial.layouts.map(() => null),
|
||||
|
||||
@@ -59,6 +59,7 @@ function makeTestAppRoute(
|
||||
isDynamic: pattern.includes(":"),
|
||||
params: [],
|
||||
rootParamNames: [],
|
||||
siblingIntercepts: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6566,6 +6566,7 @@ describe("double-encoded path handling in middleware", () => {
|
||||
unauthorizedPaths: [],
|
||||
unauthorizedPath: null,
|
||||
parallelSlots: [],
|
||||
siblingIntercepts: [],
|
||||
},
|
||||
]);
|
||||
expect(code).toContain("createAppRscRouteMatcher as __createAppRscRouteMatcher");
|
||||
|
||||
Reference in New Issue
Block a user