Files
cloudflare__vinext/tests/app-route-graph.test.ts
T
James Anderson 18e618c53e fix(app-router): preserve primary parallel route slot (#2347)
* fix(app-router): preserve primary parallel route slot

* test(app-router): match parallel default layout parity

* fix(app-router): prefer nearest parallel children owner

* fix(app-router): preserve mounted slot state after HMR

* test(app-router): wait for selected parallel slot before HMR
2026-06-28 22:59:48 +00:00

1958 lines
89 KiB
TypeScript

import { describe, it, expect, vi } from "vite-plus/test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createValidFileMatcher } from "../packages/vinext/src/routing/file-matcher.js";
import {
buildAppRouteGraph,
findOwnerRouteForDir,
type AppRouteGraphRoute,
type RouteManifest,
} from "../packages/vinext/src/routing/app-route-graph.js";
// normalizePathSeparators is a platform-gated no-op on POSIX. CI never runs
// Windows, so force the Windows behavior to let the separator-mismatch tests
// below exercise the real normalization logic. Harmless for the other tests:
// POSIX paths contain no backslashes, so the replace is an identity for them.
vi.mock("../packages/vinext/src/utils/path.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../packages/vinext/src/utils/path.js")>();
return { ...actual, normalizePathSeparators: (p: string) => p.replace(/\\/g, "/") };
});
const EMPTY_PAGE = "export default function Page() { return null; }\n";
const EMPTY_LAYOUT = "export default function Layout({ children }) { return children; }\n";
const EMPTY_ROUTE = "export async function GET() { return Response.json({ ok: true }); }\n";
async function withTempApp<T>(run: (appDir: string) => Promise<T>): Promise<T> {
const tmpDir = await mkdtemp(path.join(os.tmpdir(), "vinext-app-route-graph-"));
const appDir = path.join(tmpDir, "app");
try {
await mkdir(appDir, { recursive: true });
return await run(appDir);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
}
async function writeAppFile(appDir: string, relativePath: string, contents: string): Promise<void> {
const filePath = path.join(appDir, relativePath);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, contents);
}
function findRoute(routes: readonly AppRouteGraphRoute[], pattern: string): AppRouteGraphRoute {
const route = routes.find((candidate) => candidate.pattern === pattern);
if (!route) {
throw new Error(`Expected route ${pattern} to be materialized`);
}
return route;
}
function snapshotRouteManifest(manifest: RouteManifest) {
return {
graphVersion: manifest.graphVersion,
routes: Array.from(manifest.segmentGraph.routes.entries()),
layouts: Array.from(manifest.segmentGraph.layouts.entries()),
pages: Array.from(manifest.segmentGraph.pages.entries()),
routeHandlers: Array.from(manifest.segmentGraph.routeHandlers.entries()),
templates: Array.from(manifest.segmentGraph.templates.entries()),
slots: Array.from(manifest.segmentGraph.slots.entries()),
defaults: Array.from(manifest.segmentGraph.defaults.entries()),
slotBindings: Array.from(manifest.segmentGraph.slotBindings.entries()),
interceptions: Array.from(manifest.segmentGraph.interceptions.entries()),
interceptionsBySlotId: Array.from(manifest.segmentGraph.interceptionsBySlotId.entries()),
boundaries: Array.from(manifest.segmentGraph.boundaries.entries()),
rootBoundaries: Array.from(manifest.segmentGraph.rootBoundaries.entries()),
};
}
async function withReverseLocaleCompare<T>(run: () => Promise<T>): Promise<T> {
const originalLocaleCompare = Reflect.get(String.prototype, "localeCompare");
if (typeof originalLocaleCompare !== "function") {
throw new Error("Expected String.prototype.localeCompare to be a function");
}
// This proves RouteManifest graphVersion canonicalization does not depend on
// locale-sensitive sorting. Keep the patched window scoped to graph building.
Object.defineProperty(String.prototype, "localeCompare", {
configurable: true,
value(this: string, compareString: string) {
return Reflect.apply(originalLocaleCompare, compareString, [this]);
},
});
try {
return await run();
} finally {
Object.defineProperty(String.prototype, "localeCompare", {
configurable: true,
value: originalLocaleCompare,
});
}
}
async function createSemanticIdsFixture(appDir: string): Promise<void> {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "(marketing)/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "(marketing)/blog/[slug]/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "(marketing)/blog/[slug]/template.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "(marketing)/blog/[slug]/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "(marketing)/blog/[slug]/@modal/default.tsx", EMPTY_PAGE);
}
describe("App Router route graph builder", () => {
it("materializes pages, handlers, layouts, and inherited parallel slots", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "dashboard/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "dashboard/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "dashboard/settings/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "dashboard/api/route.ts", EMPTY_ROUTE);
await writeAppFile(appDir, "dashboard/@team/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "dashboard/@team/default.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
expect(graph.routes.map((route) => route.pattern)).toEqual([
"/dashboard",
"/dashboard/api",
"/dashboard/settings",
]);
const dashboard = findRoute(graph.routes, "/dashboard");
expect(dashboard.layouts).toEqual([
path.join(appDir, "layout.tsx"),
path.join(appDir, "dashboard/layout.tsx"),
]);
expect(dashboard.parallelSlots).toHaveLength(1);
expect(dashboard.parallelSlots[0]).toMatchObject({
key: "team@dashboard/@team",
name: "team",
pagePath: path.join(appDir, "dashboard/@team/page.tsx"),
defaultPath: path.join(appDir, "dashboard/@team/default.tsx"),
layoutIndex: 1,
routeSegments: [],
});
const settings = findRoute(graph.routes, "/dashboard/settings");
expect(settings.parallelSlots[0]).toMatchObject({
key: "team@dashboard/@team",
name: "team",
pagePath: null,
defaultPath: path.join(appDir, "dashboard/@team/default.tsx"),
layoutIndex: 1,
routeSegments: null,
});
const handler = findRoute(graph.routes, "/dashboard/api");
expect(handler).toMatchObject({
pagePath: null,
routePath: path.join(appDir, "dashboard/api/route.ts"),
});
});
});
// Guards the scan-scoped fs-probe cache (issue #1912): sibling routes share
// ancestor layouts/boundaries, and a missing root-level convention (the
// not-found probe at app/) is memoized as `null`. Every route must still
// resolve the same shared ancestor files and the same nearest boundary —
// proving the cache returns identical results, including the null-miss path.
it("resolves shared ancestors and nearest boundaries for sibling routes (probe cache)", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "dashboard/layout.tsx", EMPTY_LAYOUT);
// not-found lives at the shared dashboard ancestor; app/not-found is absent
// (its probe is memoized as null and must stay null for every descendant).
await writeAppFile(appDir, "dashboard/not-found.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "dashboard/reports/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "dashboard/reports/details/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "dashboard/settings/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const sharedLayouts = [
path.join(appDir, "layout.tsx"),
path.join(appDir, "dashboard/layout.tsx"),
];
const nearestNotFound = path.join(appDir, "dashboard/not-found.tsx");
for (const pattern of [
"/dashboard/reports",
"/dashboard/reports/details",
"/dashboard/settings",
]) {
const route = findRoute(graph.routes, pattern);
// Shared ancestor layouts resolve identically for every sibling.
expect(route.layouts).toEqual(sharedLayouts);
// Nearest not-found walks up to the shared dashboard boundary.
expect(route.notFoundPath).toBe(nearestNotFound);
}
});
});
it("materializes synthetic routes from nested parallel slot pages", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "dashboard/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "dashboard/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "dashboard/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "dashboard/@team/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "dashboard/@team/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "dashboard/@team/members/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
expect(graph.routes.map((route) => route.pattern)).toEqual([
"/dashboard",
"/dashboard/members",
]);
const members = findRoute(graph.routes, "/dashboard/members");
expect(members).toMatchObject({
pagePath: path.join(appDir, "dashboard/default.tsx"),
routePath: null,
routeSegments: ["dashboard", "members"],
childrenRouteSegments: ["dashboard"],
patternParts: ["dashboard", "members"],
});
expect(members.parallelSlots[0]).toMatchObject({
key: "team@dashboard/@team",
name: "team",
pagePath: path.join(appDir, "dashboard/@team/members/page.tsx"),
routeSegments: ["members"],
});
});
});
// Regression for https://github.com/cloudflare/vinext/issues/1339
// Ported from Next.js: test/e2e/app-dir/parallel-routes-layouts/
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/parallel-routes-layouts/parallel-routes-layouts.test.ts
//
// When a slot owns both a matched page and a default.tsx, the matched page
// must win — vinext previously fell back to default.tsx ("default page"
// instead of "Hello from Nested"). This locks in page-over-default priority
// for the children slot and for sibling @foo/@bar slots simultaneously.
it("prefers a matched slot page over default.tsx across sibling slots (issue #1339)", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@global/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "nested/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "nested/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "nested/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "nested/@foo/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "nested/@foo/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "nested/@foo/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "nested/@bar/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "nested/@bar/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "nested/@bar/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "nested/@bar/subroute/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "nested/@bar/modal/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "nested/settings/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
// At /nested the children slot must render nested/page.tsx, not the
// sibling nested/default.tsx fallback.
const nested = findRoute(graph.routes, "/nested");
expect(nested.pagePath).toBe(path.join(appDir, "nested/page.tsx"));
expect(nested.childrenSlot).toEqual({
id: "slot:children:/nested",
ownerTreePath: "/nested",
state: "active",
});
expect(nested.parallelSlots.some((slot) => slot.ownerTreePath === "/")).toBe(true);
// Each sibling slot has its own page AND its own default — the page wins.
const foo = nested.parallelSlots.find((slot) => slot.name === "foo");
const bar = nested.parallelSlots.find((slot) => slot.name === "bar");
expect(foo).toMatchObject({
name: "foo",
pagePath: path.join(appDir, "nested/@foo/page.tsx"),
defaultPath: path.join(appDir, "nested/@foo/default.tsx"),
routeSegments: [],
});
expect(bar).toMatchObject({
name: "bar",
pagePath: path.join(appDir, "nested/@bar/page.tsx"),
defaultPath: path.join(appDir, "nested/@bar/default.tsx"),
routeSegments: [],
});
// /nested/subroute only has a match for the @bar slot. The children slot
// falls back to nested/default.tsx, @bar mirrors its subroute page, and
// @foo (no subroute page) keeps its default fallback.
const subroute = findRoute(graph.routes, "/nested/subroute");
expect(subroute.pagePath).toBe(path.join(appDir, "nested/default.tsx"));
expect(subroute.childrenSlot).toEqual({
id: "slot:children:/nested",
ownerTreePath: "/nested",
state: "default",
});
const subBar = subroute.parallelSlots.find((slot) => slot.name === "bar");
const subFoo = subroute.parallelSlots.find((slot) => slot.name === "foo");
expect(subBar).toMatchObject({
name: "bar",
pagePath: path.join(appDir, "nested/@bar/subroute/page.tsx"),
routeSegments: ["subroute"],
});
expect(subFoo).toMatchObject({
name: "foo",
pagePath: null,
defaultPath: path.join(appDir, "nested/@foo/default.tsx"),
});
expect(
graph.routeManifest.segmentGraph.slotBindings.get("route:/nested::slot:children:/nested"),
).toMatchObject({
ownerLayoutId: "layout:/nested",
slotId: "slot:children:/nested",
state: "active",
});
expect(
graph.routeManifest.segmentGraph.slotBindings.get(
"route:/nested/subroute::slot:children:/nested",
),
).toMatchObject({
ownerLayoutId: "layout:/nested",
slotId: "slot:children:/nested",
state: "default",
});
const settings = findRoute(graph.routes, "/nested/settings");
expect(settings.pagePath).toBe(path.join(appDir, "nested/settings/page.tsx"));
expect(settings.childrenSlot).toEqual({
id: "slot:children:/nested",
ownerTreePath: "/nested",
state: "active",
});
expect(
graph.routeManifest.segmentGraph.slotBindings.get(
"route:/nested/settings::slot:children:/nested",
),
).toMatchObject({
ownerLayoutId: "layout:/nested",
slotId: "slot:children:/nested",
state: "active",
});
const modal = findRoute(graph.routes, "/nested/modal");
expect(modal.pagePath).toBe(path.join(appDir, "nested/default.tsx"));
expect(modal.childrenSlot).toEqual({
id: "slot:children:/nested",
ownerTreePath: "/nested",
state: "default",
});
});
});
it("prefers the nearest implicit children owner for nested parallel-route families", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "dashboard/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "dashboard/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "dashboard/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "dashboard/@analytics/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "dashboard/@analytics/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "dashboard/@analytics/sub/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "dashboard/team/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "dashboard/team/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "dashboard/team/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "dashboard/team/@members/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "dashboard/team/@members/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "dashboard/team/@members/sub/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const team = findRoute(graph.routes, "/dashboard/team");
expect(team.childrenSlot).toEqual({
id: "slot:children:/dashboard/team",
ownerTreePath: "/dashboard/team",
state: "active",
});
const teamSubroute = findRoute(graph.routes, "/dashboard/team/sub");
expect(teamSubroute.childrenSlot).toEqual({
id: "slot:children:/dashboard/team",
ownerTreePath: "/dashboard/team",
state: "default",
});
expect(
graph.routeManifest.segmentGraph.slotBindings.get(
"route:/dashboard/team::slot:children:/dashboard/team",
),
).toMatchObject({
ownerLayoutId: "layout:/dashboard/team",
state: "active",
});
});
});
it("materializes synthetic routes from a sibling route-group's parallel slot", async () => {
// Two sibling route groups share the same URL pattern at the root:
// (group-a) provides a layout-only route with a catch-all parallel slot
// (group-b) provides the children page at the same URL pattern
// The (group-a) layout cannot become a route on its own (collision), but
// its slot's nested catch-all page must still materialize a synthetic
// route so that URLs not matched by (group-b) fall through to the slot.
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "(group-a)/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "(group-a)/@parallel/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "(group-a)/@parallel/[...catcher]/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "(group-b)/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "(group-b)/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "(group-b)/foo/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const patterns = graph.routes.map((r) => r.pattern).sort();
// Real page routes from (group-b)
expect(patterns).toContain("/");
expect(patterns).toContain("/foo");
// Synthetic catch-all from (group-a)'s @parallel slot
expect(patterns).toContain("/:catcher+");
const catcher = findRoute(graph.routes, "/:catcher+");
// The layout-only ghost parent is not added to routes, but the synthetic
// sub-route inherits (group-a)'s layout chain.
expect(catcher.layouts).toEqual([path.join(appDir, "(group-a)/layout.tsx")]);
expect(catcher.parallelSlots).toHaveLength(1);
expect(catcher.parallelSlots[0]).toMatchObject({
name: "parallel",
pagePath: path.join(appDir, "(group-a)/@parallel/[...catcher]/page.tsx"),
routeSegments: ["[...catcher]"],
});
// The real /foo route belongs to (group-b) only — it must not pick up
// slots from the sibling group.
const foo = findRoute(graph.routes, "/foo");
expect(foo.parallelSlots).toHaveLength(0);
});
});
it("skips synthetic routes that structurally conflict with existing page routes", async () => {
// A slot sub-page like @feed/[name]/page.tsx under /shop would create /shop/:name,
// but if /shop/[id]/page.tsx already exists (route /shop/:id), the synthetic route
// must be skipped — validateRoutePatterns rejects different slug names at the same
// dynamic path. The slot content is resolved at render time by findMirroredSlotPage.
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "shop/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "shop/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "shop/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "shop/[id]/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "shop/@feed/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "shop/@feed/[name]/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const patterns = graph.routes.map((r) => r.pattern).sort();
// /shop/:id from shop/[id]/page.tsx must exist
expect(patterns).toContain("/shop/:id");
// /shop/:name from the slot sub-page must NOT be materialized
expect(patterns).not.toContain("/shop/:name");
// The non-conflicting parent route /shop should still exist
expect(patterns).toContain("/shop");
});
});
it("does not create synthetic routes under route-handler-only parents", async () => {
// Route handlers have pagePath: null but are NOT layout-only UI routes.
// They must not enter discoverSlotSubRoutes, or an ancestor slot like
// @feed/foo/page.tsx could materialise a nonsense synthetic route under
// /api/foo.
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "@feed/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@feed/foo/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "api/route.ts", EMPTY_ROUTE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const patterns = graph.routes.map((r) => r.pattern).sort();
// /api from the route handler must exist
expect(patterns).toContain("/api");
// /api/foo must NOT be materialised from the route handler entry
expect(patterns).not.toContain("/api/foo");
// /foo from the ancestor slot must still be discovered normally
expect(patterns).toContain("/foo");
});
});
it("skips structural conflicts against synthetic routes created earlier in the same pass", async () => {
// Two slot sub-pages with different param names under the same parent
// should not both be materialised. The first synthetic route (/shop/:id)
// must block the second (/shop/:name), or validateRoutePatterns will
// reject the build with "different slug names".
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "shop/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "shop/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "shop/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "shop/@a/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "shop/@a/[id]/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "shop/@b/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "shop/@b/[name]/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const patterns = graph.routes.map((r) => r.pattern).sort();
// Only one of /shop/:id or /shop/:name should be materialised
const conflictingSyntheticPatterns = patterns.filter(
(pattern) => pattern === "/shop/:id" || pattern === "/shop/:name",
);
expect(conflictingSyntheticPatterns).toHaveLength(1);
expect(patterns).toContain("/shop");
});
});
// Regression for https://github.com/cloudflare/vinext/issues/1535
// Ported from Next.js: test/e2e/app-dir/parallel-routes-catchall-children-slot/
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/parallel-routes-catchall-children-slot/parallel-routes-catchall-children-slot.test.ts
describe("@children slot priority (issue #1535)", () => {
it("uses @children/page.tsx as the page for '/' over a sibling [...catchAll]", async () => {
// app/@children/page.tsx provides the layout's `children` prop at '/'.
// app/[...catchAll]/page.tsx is a catch-all that should NOT win for '/'.
// Next.js parity: see normalize-catchall-routes.ts (@children is not
// a "matchable slot" so the catchall does not displace it).
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "@children/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@slot/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@slot/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[...catchAll]/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const patterns = graph.routes.map((r) => r.pattern).sort();
// The root URL must resolve as a real page, sourced from @children/page.tsx.
expect(patterns).toContain("/");
const root = findRoute(graph.routes, "/");
expect(root.pagePath).toBe(path.join(appDir, "@children/page.tsx"));
// The catch-all must still cover deeper paths.
expect(patterns).toContain("/:catchAll+");
// The @slot slot is attached to '/', not consumed as a top-level route.
const slotNames = root.parallelSlots.map((s) => s.name).sort();
expect(slotNames).toContain("slot");
});
});
it("resolves '/nested' to @children/page when only the @children slot exists (no default)", async () => {
// The nested directory has a layout and a @children slot with a page,
// but no default.tsx for the children slot. The route '/nested' must
// still materialize and the page must come from @children/page.tsx.
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "@slot/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@slot/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[...catchAll]/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "nested/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "nested/@children/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const patterns = graph.routes.map((r) => r.pattern).sort();
expect(patterns).toContain("/nested");
const nested = findRoute(graph.routes, "/nested");
expect(nested.pagePath).toBe(path.join(appDir, "nested/@children/page.tsx"));
});
});
});
// Regression for https://github.com/cloudflare/vinext/issues/1535
// Ported from Next.js: test/e2e/app-dir/parallel-routes-catchall/
// ("should match correctly when defining an explicit slot but no page").
describe("explicit slot but no page (issue #1535)", () => {
// Ported from Next.js:
// test/e2e/app-dir/parallel-routes-leaf-segments/fixtures/no-build-error/app/no-children
// https://github.com/vercel/next.js/tree/v16.2.6/test/e2e/app-dir/parallel-routes-leaf-segments/fixtures/no-build-error/app/no-children
it("uses the parent children default for a leaf parallel-slot sub-route", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "no-children/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "no-children/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "no-children/@slot/other/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const route = findRoute(graph.routes, "/no-children/other");
expect(route.pagePath).toBe(path.join(appDir, "no-children/default.tsx"));
expect(route.parallelSlots.find((slot) => slot.name === "slot")?.pagePath).toBe(
path.join(appDir, "no-children/@slot/other/page.tsx"),
);
});
});
it("falls children through to the sibling catch-all for a slot-only sub-route", async () => {
// /baz: @slot/baz/page.tsx exists, but there is no baz/page.tsx and no
// root default.tsx. Next.js serves /baz's children from the sibling
// [...catchAll]/page.tsx ("main catchall") while the @slot slot renders
// @slot/baz/page.tsx ("baz slot"). Without the catch-all children
// fallback the synthetic /baz route shadows the catch-all with an empty
// children prop and the request hangs.
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@slot/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@slot/baz/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "@slot/foo/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@slot/baz/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@slot/[...catchAll]/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[...catchAll]/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "foo/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "bar/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const patterns = graph.routes.map((r) => r.pattern).sort();
// The slot-only sub-route must materialise so @slot/baz wins over the
// slot's own catch-all.
expect(patterns).toContain("/baz");
const baz = findRoute(graph.routes, "/baz");
// Children fall through to the sibling catch-all (not null → no hang).
expect(baz.pagePath).toBe(path.join(appDir, "[...catchAll]/page.tsx"));
// The @slot slot resolves to the explicit @slot/baz page, not the
// slot's catch-all.
const slot = baz.parallelSlots.find((s) => s.name === "slot");
expect(slot?.pagePath).toBe(path.join(appDir, "@slot/baz/page.tsx"));
expect(slot?.configLayoutPaths).toEqual([path.join(appDir, "@slot/baz/layout.tsx")]);
// The top-level catch-all is still present for fully-unmatched paths.
expect(patterns).toContain("/:catchAll+");
});
});
it("builds the slot-only catch-all sub-route with a static pattern (documents params limitation)", async () => {
// The synthetic /baz route's URL pattern is static, so its catch-all
// children page receives empty params at render time (Next.js would pass
// params.catchAll = ["baz"]). Lock in the current shape so a future fix
// that populates the catch-all param has to update this assertion. See
// the "Known limitation" note in discoverSlotSubRoutes.
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@slot/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@slot/baz/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[...catchAll]/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const baz = findRoute(graph.routes, "/baz");
expect(baz.pagePath).toBe(path.join(appDir, "[...catchAll]/page.tsx"));
// Static pattern → no catch-all param captured for the children page.
expect(baz.isDynamic).toBe(false);
expect(baz.params).toEqual([]);
expect(baz.patternParts).toEqual(["baz"]);
});
});
it("prefers a children default.tsx over the catch-all when both exist", async () => {
// When the parent provides a default.tsx for the children slot, it wins
// over a sibling catch-all (default.tsx is the canonical children
// fallback). This guards the new catch-all fallback from displacing it.
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@slot/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@slot/baz/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[...catchAll]/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const baz = findRoute(graph.routes, "/baz");
expect(baz.pagePath).toBe(path.join(appDir, "default.tsx"));
});
});
});
// Ported from Next.js: test/e2e/app-dir/catchall-parallel-routes-group/
it("discovers a parallel slot page inside a route group (catchall-parallel-routes-group)", async () => {
// Fixture mirrors the e2e test:
// app/[...catchAll]/layout.tsx — layout with `slot` prop
// app/[...catchAll]/page.tsx — children page
// app/[...catchAll]/@slot/layout.tsx
// app/[...catchAll]/@slot/(group)/page.tsx ← page inside route group
//
// The slot has NO direct page.tsx at the @slot root; the page lives inside
// a transparent route-group directory. discoverParallelSlots must still
// find and include the slot so it renders correctly.
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[...catchAll]/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "[...catchAll]/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[...catchAll]/@slot/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "[...catchAll]/@slot/(group)/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const catchAll = findRoute(graph.routes, "/:catchAll+");
// The slot must be present and resolve to the page inside the route group.
expect(catchAll.parallelSlots).toHaveLength(1);
expect(catchAll.parallelSlots[0]).toMatchObject({
name: "slot",
pagePath: path.join(appDir, "[...catchAll]/@slot/(group)/page.tsx"),
hasPage: true,
// The route group is transparent in the URL, so routeSegments is empty.
routeSegments: [],
});
});
});
// Ported from Next.js:
// test/e2e/app-dir/parallel-routes-catchall-default/parallel-routes-catchall-default.test.ts
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/parallel-routes-catchall-default/parallel-routes-catchall-default.test.ts
it("matches nested parallel slot pages before an ancestor optional catch-all", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[locale]/[[...catchAll]]/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[locale]/nested/[foo]/[bar]/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "[locale]/nested/[foo]/[bar]/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[locale]/nested/[foo]/[bar]/@slot/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[locale]/nested/[foo]/[bar]/@slot/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[locale]/nested/[foo]/[bar]/@slot/[baz]/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[locale]/nested/[foo]/[bar]/@slot/[baz]/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const nested = findRoute(graph.routes, "/:locale/nested/:foo/:bar");
const nestedBaz = findRoute(graph.routes, "/:locale/nested/:foo/:bar/:baz");
expect(nested.pagePath).toBe(path.join(appDir, "[locale]/nested/[foo]/[bar]/default.tsx"));
expect(nested.parallelSlots.find((slot) => slot.name === "slot")?.pagePath).toBe(
path.join(appDir, "[locale]/nested/[foo]/[bar]/@slot/page.tsx"),
);
expect(nestedBaz.pagePath).toBe(path.join(appDir, "[locale]/nested/[foo]/[bar]/default.tsx"));
expect(nestedBaz.parallelSlots.find((slot) => slot.name === "slot")?.pagePath).toBe(
path.join(appDir, "[locale]/nested/[foo]/[bar]/@slot/[baz]/page.tsx"),
);
});
});
// Ported from Next.js:
// test/e2e/app-dir/parallel-routes-catchall-slotted-non-catchalls/parallel-routes-catchall-slotted-non-catchalls.test.ts
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/parallel-routes-catchall-slotted-non-catchalls/parallel-routes-catchall-slotted-non-catchalls.test.ts
it("lets an optional catch-all supply children for a layout-only slot owner", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[locale]/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "[locale]/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[locale]/@slot/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[locale]/@slot/other/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[locale]/[[...catchAll]]/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const localeCatchAll = findRoute(graph.routes, "/:locale/:catchAll*");
expect(graph.routes.some((route) => route.pattern === "/:locale")).toBe(false);
expect(localeCatchAll.parallelSlots.find((slot) => slot.name === "slot")).toMatchObject({
pagePath: null,
defaultPath: path.join(appDir, "[locale]/@slot/default.tsx"),
});
});
});
it("keeps a standalone layout-only slot owner as a route", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "parallel-nested/home/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "parallel-nested/home/@parallel/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "parallel-nested/home/@parallel/nested/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const parent = findRoute(graph.routes, "/parallel-nested/home");
const nested = findRoute(graph.routes, "/parallel-nested/home/nested");
expect(parent.pagePath).toBeNull();
expect(parent.parallelSlots.find((slot) => slot.name === "parallel")?.defaultPath).toBe(
path.join(appDir, "parallel-nested/home/@parallel/default.tsx"),
);
expect(nested.parallelSlots.find((slot) => slot.name === "parallel")?.pagePath).toBe(
path.join(appDir, "parallel-nested/home/@parallel/nested/page.tsx"),
);
});
});
// Ported from Next.js: test/e2e/app-dir/parallel-routes-group-depth/parallel-routes-group-depth.test.ts
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/parallel-routes-group-depth/parallel-routes-group-depth.test.ts
it("keeps a sibling slot active when children are inside a route group", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "group-depth/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "group-depth/(children)/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "group-depth/(children)/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "group-depth/@slot/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "group-depth/@slot/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const route = findRoute(graph.routes, "/group-depth");
const slot = route.parallelSlots.find((candidate) => candidate.name === "slot");
expect(route.pagePath).toBe(path.join(appDir, "group-depth/(children)/page.tsx"));
expect(slot).toMatchObject({
pagePath: path.join(appDir, "group-depth/@slot/page.tsx"),
layoutPath: path.join(appDir, "group-depth/@slot/layout.tsx"),
routeSegments: [],
});
});
});
it("records nested active slot layouts for segment config reduction", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "dashboard/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@slot/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@slot/dashboard/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "@slot/dashboard/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const route = findRoute(graph.routes, "/dashboard");
const slot = route.parallelSlots.find((candidate) => candidate.name === "slot");
expect(slot).toMatchObject({
pagePath: path.join(appDir, "@slot/dashboard/page.tsx"),
configLayoutPaths: [path.join(appDir, "@slot/dashboard/layout.tsx")],
configLayoutTreePositions: [1],
});
});
});
it("keeps route groups transparent in materialized URL patterns", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "(marketing)/about/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
expect(graph.routes.map((route) => route.pattern)).toEqual(["/about"]);
const about = findRoute(graph.routes, "/about");
expect(about).toMatchObject({
pagePath: path.join(appDir, "(marketing)/about/page.tsx"),
routeSegments: ["(marketing)", "about"],
patternParts: ["about"],
});
});
});
it("discovers error boundaries in route groups without sibling layouts", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(
appDir,
"docs/(group)/error.tsx",
"export default function Error() { return null; }\n",
);
await writeAppFile(appDir, "docs/(group)/child/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher(["tsx"]));
const route = findRoute(graph.routes, "/docs/child");
expect(route.layoutTreePositions).toEqual([0]);
expect(route.layoutErrorPaths).toEqual([null]);
expect(route.errorPaths).toEqual([path.join(appDir, "docs/(group)/error.tsx")]);
expect(route.errorTreePositions).toEqual([2]);
});
});
it("mints semantic ids for routes, entries, layouts, templates, and slots", async () => {
await withTempApp(async (appDir) => {
await createSemanticIdsFixture(appDir);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const graphRoutes: readonly AppRouteGraphRoute[] = graph.routes;
const route = findRoute(graph.routes, "/blog/:slug");
expect(graphRoutes).toHaveLength(1);
expect(route.ids).toEqual({
route: "route:/blog/:slug",
page: "page:/blog/:slug",
routeHandler: null,
rootBoundary: "root-boundary:/",
layouts: ["layout:/", "layout:/(marketing)", "layout:/(marketing)/blog/[slug]"],
templates: ["template:/(marketing)/blog/[slug]"],
slots: {
"modal@(marketing)/blog/[slug]/@modal": "slot:modal:/(marketing)/blog/[slug]",
},
});
expect(route.parallelSlots[0]).toMatchObject({
id: "slot:modal:/(marketing)/blog/[slug]",
key: "modal@(marketing)/blog/[slug]/@modal",
});
});
});
it("materializes slot-local layouts in the static segment graph", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@modal/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "@modal/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const { segmentGraph } = graph.routeManifest;
expect(segmentGraph.layouts.get("layout:/@modal")).toEqual({
id: "layout:/@modal",
treePath: "/@modal",
patternParts: [],
paramNames: [],
rootBoundaryId: "root-boundary:/",
});
});
});
it("exposes a minimal RouteManifest read model keyed by semantic ids", async () => {
await withTempApp(async (appDir) => {
await createSemanticIdsFixture(appDir);
await writeAppFile(appDir, "(marketing)/api/route.ts", EMPTY_ROUTE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const manifest = graph.routeManifest;
const segmentGraph = manifest.segmentGraph;
expect(manifest.graphVersion).toMatch(/^graph:[a-f0-9]{64}$/);
expect(segmentGraph.routes.get("route:/blog/:slug")).toEqual({
id: "route:/blog/:slug",
pattern: "/blog/:slug",
patternParts: ["blog", ":slug"],
isDynamic: true,
paramNames: ["slug"],
rootParamNames: [],
rootBoundaryId: "root-boundary:/",
pageId: "page:/blog/:slug",
routeHandlerId: null,
layoutIds: ["layout:/", "layout:/(marketing)", "layout:/(marketing)/blog/[slug]"],
templateIds: ["template:/(marketing)/blog/[slug]"],
slotIds: ["slot:modal:/(marketing)/blog/[slug]"],
});
expect(segmentGraph.routes.get("route:/api")).toEqual({
id: "route:/api",
pattern: "/api",
patternParts: ["api"],
isDynamic: false,
paramNames: [],
rootParamNames: [],
rootBoundaryId: "root-boundary:/",
pageId: null,
routeHandlerId: "route-handler:/api",
layoutIds: ["layout:/", "layout:/(marketing)"],
templateIds: [],
slotIds: [],
});
expect(segmentGraph.pages.get("page:/blog/:slug")).toEqual({
id: "page:/blog/:slug",
routeId: "route:/blog/:slug",
pattern: "/blog/:slug",
});
expect(segmentGraph.routeHandlers.get("route-handler:/api")).toEqual({
id: "route-handler:/api",
routeId: "route:/api",
pattern: "/api",
});
expect(segmentGraph.layouts.get("layout:/(marketing)/blog/[slug]")).toEqual({
id: "layout:/(marketing)/blog/[slug]",
treePath: "/(marketing)/blog/[slug]",
patternParts: ["blog", ":slug"],
paramNames: ["slug"],
rootBoundaryId: "root-boundary:/",
});
expect(segmentGraph.templates.get("template:/(marketing)/blog/[slug]")).toEqual({
id: "template:/(marketing)/blog/[slug]",
treePath: "/(marketing)/blog/[slug]",
rootBoundaryId: "root-boundary:/",
ownerLayoutId: "layout:/(marketing)/blog/[slug]",
reset: {
kind: "remountSubtree",
treePath: "/(marketing)/blog/[slug]",
},
});
expect(segmentGraph.slots.get("slot:modal:/(marketing)/blog/[slug]")).toEqual({
id: "slot:modal:/(marketing)/blog/[slug]",
key: "modal@(marketing)/blog/[slug]/@modal",
name: "modal",
ownerTreePath: "/(marketing)/blog/[slug]",
ownerLayoutId: "layout:/(marketing)/blog/[slug]",
rootBoundaryId: "root-boundary:/",
defaultId: "default:slot:modal:/(marketing)/blog/[slug]",
hasDefault: true,
hasPage: false,
});
expect(segmentGraph.defaults.get("default:slot:modal:/(marketing)/blog/[slug]")).toEqual({
id: "default:slot:modal:/(marketing)/blog/[slug]",
slotId: "slot:modal:/(marketing)/blog/[slug]",
ownerLayoutId: "layout:/(marketing)/blog/[slug]",
ownerTreePath: "/(marketing)/blog/[slug]",
rootBoundaryId: "root-boundary:/",
});
expect(segmentGraph.rootBoundaries.get("root-boundary:/")).toEqual({
id: "root-boundary:/",
layoutId: "layout:/",
treePath: "/",
});
});
});
it("mints distinct root boundary ids for route-group root layouts", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "(marketing)/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "(marketing)/marketing/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "(shop)/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "(shop)/shop/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const rootBoundaryIds = graph.routes
.map((route) => route.ids.rootBoundary)
.sort((left, right) => {
const leftKey = String(left);
const rightKey = String(right);
if (leftKey < rightKey) return -1;
if (leftKey > rightKey) return 1;
return 0;
});
expect(rootBoundaryIds).toEqual(["root-boundary:/(marketing)", "root-boundary:/(shop)"]);
expect(Array.from(graph.routeManifest.segmentGraph.rootBoundaries.keys()).sort()).toEqual([
"root-boundary:/(marketing)",
"root-boundary:/(shop)",
]);
});
});
it("uses null rootBoundaryId when a route has no layout boundary", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layoutless/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const route = findRoute(graph.routes, "/layoutless");
expect(route.ids.rootBoundary).toBeNull();
expect(graph.routeManifest.segmentGraph.routes.get("route:/layoutless")).toMatchObject({
id: "route:/layoutless",
rootBoundaryId: null,
layoutIds: [],
});
expect(graph.routeManifest.segmentGraph.rootBoundaries.size).toBe(0);
});
});
it("exposes RouteManifest facts for route groups, slots, templates, and boundaries", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "(marketing)/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "(marketing)/template.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "(marketing)/error.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "(marketing)/not-found.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "(marketing)/dashboard/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "(marketing)/dashboard/template.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "(marketing)/dashboard/forbidden.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "(marketing)/dashboard/unauthorized.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "(marketing)/dashboard/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "(marketing)/dashboard/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "(marketing)/dashboard/@analytics/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "(marketing)/dashboard/@modal/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "(marketing)/dashboard/@modal/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "(marketing)/dashboard/@modal/settings/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const segmentGraph = graph.routeManifest.segmentGraph;
expect(segmentGraph.slots.get("slot:analytics:/(marketing)/dashboard")).toEqual({
id: "slot:analytics:/(marketing)/dashboard",
key: "analytics@(marketing)/dashboard/@analytics",
name: "analytics",
ownerTreePath: "/(marketing)/dashboard",
ownerLayoutId: "layout:/(marketing)/dashboard",
rootBoundaryId: "root-boundary:/(marketing)",
defaultId: "default:slot:analytics:/(marketing)/dashboard",
hasDefault: true,
hasPage: false,
});
expect(segmentGraph.slots.get("slot:modal:/(marketing)/dashboard")).toMatchObject({
ownerTreePath: "/(marketing)/dashboard",
ownerLayoutId: "layout:/(marketing)/dashboard",
rootBoundaryId: "root-boundary:/(marketing)",
defaultId: "default:slot:modal:/(marketing)/dashboard",
hasDefault: true,
hasPage: true,
});
expect(segmentGraph.defaults.get("default:slot:analytics:/(marketing)/dashboard")).toEqual({
id: "default:slot:analytics:/(marketing)/dashboard",
slotId: "slot:analytics:/(marketing)/dashboard",
ownerLayoutId: "layout:/(marketing)/dashboard",
ownerTreePath: "/(marketing)/dashboard",
rootBoundaryId: "root-boundary:/(marketing)",
});
expect(
segmentGraph.slotBindings.get("route:/dashboard::slot:analytics:/(marketing)/dashboard"),
).toEqual({
id: "route:/dashboard::slot:analytics:/(marketing)/dashboard",
routeId: "route:/dashboard",
slotId: "slot:analytics:/(marketing)/dashboard",
ownerLayoutId: "layout:/(marketing)/dashboard",
state: "default",
defaultId: "default:slot:analytics:/(marketing)/dashboard",
routeSegments: null,
});
expect(
segmentGraph.slotBindings.get("route:/dashboard::slot:modal:/(marketing)/dashboard"),
).toEqual({
id: "route:/dashboard::slot:modal:/(marketing)/dashboard",
routeId: "route:/dashboard",
slotId: "slot:modal:/(marketing)/dashboard",
ownerLayoutId: "layout:/(marketing)/dashboard",
state: "active",
defaultId: null,
routeSegments: [],
});
expect(
segmentGraph.slotBindings.get(
"route:/dashboard/settings::slot:modal:/(marketing)/dashboard",
),
).toEqual({
id: "route:/dashboard/settings::slot:modal:/(marketing)/dashboard",
routeId: "route:/dashboard/settings",
slotId: "slot:modal:/(marketing)/dashboard",
ownerLayoutId: "layout:/(marketing)/dashboard",
state: "active",
defaultId: null,
routeSegments: ["settings"],
});
expect(segmentGraph.templates.get("template:/(marketing)")).toEqual({
id: "template:/(marketing)",
treePath: "/(marketing)",
rootBoundaryId: "root-boundary:/(marketing)",
ownerLayoutId: "layout:/(marketing)",
reset: {
kind: "remountSubtree",
treePath: "/(marketing)",
},
});
expect(segmentGraph.templates.get("template:/(marketing)/dashboard")).toEqual({
id: "template:/(marketing)/dashboard",
treePath: "/(marketing)/dashboard",
rootBoundaryId: "root-boundary:/(marketing)",
ownerLayoutId: "layout:/(marketing)/dashboard",
reset: {
kind: "remountSubtree",
treePath: "/(marketing)/dashboard",
},
});
expect(segmentGraph.boundaries.get("boundary:error:/(marketing)")).toEqual({
id: "boundary:error:/(marketing)",
outcome: "error",
treePath: "/(marketing)",
ownerLayoutId: "layout:/(marketing)",
rootBoundaryId: "root-boundary:/(marketing)",
});
expect(segmentGraph.boundaries.get("boundary:notFound:/(marketing)")).toEqual({
id: "boundary:notFound:/(marketing)",
outcome: "notFound",
treePath: "/(marketing)",
ownerLayoutId: "layout:/(marketing)",
rootBoundaryId: "root-boundary:/(marketing)",
});
expect(segmentGraph.boundaries.get("boundary:forbidden:/(marketing)/dashboard")).toEqual({
id: "boundary:forbidden:/(marketing)/dashboard",
outcome: "forbidden",
treePath: "/(marketing)/dashboard",
ownerLayoutId: "layout:/(marketing)/dashboard",
rootBoundaryId: "root-boundary:/(marketing)",
});
expect(segmentGraph.boundaries.get("boundary:unauthorized:/(marketing)/dashboard")).toEqual({
id: "boundary:unauthorized:/(marketing)/dashboard",
outcome: "unauthorized",
treePath: "/(marketing)/dashboard",
ownerLayoutId: "layout:/(marketing)/dashboard",
rootBoundaryId: "root-boundary:/(marketing)",
});
});
});
it("exposes segment error boundary facts even when the segment has no sibling layout", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "docs/(group)/error.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "docs/(group)/child/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const boundary = graph.routeManifest.segmentGraph.boundaries.get(
"boundary:error:/docs/(group)",
);
expect(boundary).toEqual({
id: "boundary:error:/docs/(group)",
outcome: "error",
treePath: "/docs/(group)",
ownerLayoutId: null,
rootBoundaryId: "root-boundary:/",
});
expect(graph.routeManifest.segmentGraph.boundaries.has("boundary:error:/docs")).toBe(false);
});
});
it("keeps semantic ids stable across different filesystem roots", async () => {
const firstIds = await withTempApp(async (appDir) => {
await createSemanticIdsFixture(appDir);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
return findRoute(graph.routes, "/blog/:slug").ids;
});
const secondIds = await withTempApp(async (appDir) => {
await createSemanticIdsFixture(appDir);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
return findRoute(graph.routes, "/blog/:slug").ids;
});
expect(firstIds).toBeDefined();
expect(secondIds).toEqual(firstIds);
});
it("keeps RouteManifest graph output stable across different filesystem roots", async () => {
const firstManifest = await withTempApp(async (appDir) => {
await createSemanticIdsFixture(appDir);
await writeAppFile(appDir, "(marketing)/api/route.ts", EMPTY_ROUTE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
return snapshotRouteManifest(graph.routeManifest);
});
const secondManifest = await withTempApp(async (appDir) => {
await createSemanticIdsFixture(appDir);
await writeAppFile(appDir, "(marketing)/api/route.ts", EMPTY_ROUTE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
return snapshotRouteManifest(graph.routeManifest);
});
expect(secondManifest).toEqual(firstManifest);
});
it("does not let locale collation affect RouteManifest graphVersion", async () => {
const graphVersions = await withTempApp(async (appDir) => {
await createSemanticIdsFixture(appDir);
const normalGraph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const reverseLocaleGraph = await withReverseLocaleCompare(() =>
buildAppRouteGraph(appDir, createValidFileMatcher()),
);
return [
normalGraph.routeManifest.graphVersion,
reverseLocaleGraph.routeManifest.graphVersion,
];
});
expect(graphVersions[1]).toBe(graphVersions[0]);
});
it("links inherited parallel slot to a mirrored sub-page (literal segments)", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "about/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@breadcrumbs/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@breadcrumbs/about/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "@breadcrumbs/about/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const about = findRoute(graph.routes, "/about");
expect(about.parallelSlots).toHaveLength(1);
expect(about.parallelSlots[0]).toMatchObject({
name: "breadcrumbs",
pagePath: path.join(appDir, "@breadcrumbs/about/page.tsx"),
defaultPath: path.join(appDir, "@breadcrumbs/default.tsx"),
configLayoutPaths: [path.join(appDir, "@breadcrumbs/about/layout.tsx")],
routeSegments: ["about"],
});
});
});
it("links inherited parallel slot to a mirrored sub-page (catch-all segments)", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "[...slug]/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@breadcrumbs/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@breadcrumbs/[...slug]/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const slug = findRoute(graph.routes, "/:slug+");
expect(slug.parallelSlots[0]).toMatchObject({
name: "breadcrumbs",
pagePath: path.join(appDir, "@breadcrumbs/[...slug]/page.tsx"),
routeSegments: ["[...slug]"],
});
});
});
it("falls back to default when no mirrored sub-page exists in the inherited slot", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "about/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@breadcrumbs/default.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const about = findRoute(graph.routes, "/about");
expect(about.parallelSlots[0]).toMatchObject({
name: "breadcrumbs",
pagePath: null,
defaultPath: path.join(appDir, "@breadcrumbs/default.tsx"),
routeSegments: null,
});
});
});
it("links inherited parallel slot to a mirror across a route group", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "(marketing)/about/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@breadcrumbs/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@breadcrumbs/about/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const about = findRoute(graph.routes, "/about");
expect(about.parallelSlots[0]).toMatchObject({
name: "breadcrumbs",
pagePath: path.join(appDir, "@breadcrumbs/about/page.tsx"),
defaultPath: path.join(appDir, "@breadcrumbs/default.tsx"),
routeSegments: ["about"],
});
});
});
it("mirrors across multiple inherited segments", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "shop/items/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@breadcrumbs/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@breadcrumbs/shop/items/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const items = findRoute(graph.routes, "/shop/items");
expect(items.parallelSlots[0]).toMatchObject({
pagePath: path.join(appDir, "@breadcrumbs/shop/items/page.tsx"),
routeSegments: ["shop", "items"],
});
});
});
it("captures distinct slotPatternParts/slotParamNames when slot and route use different param names", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "shop/[id]/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@breadcrumbs/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@breadcrumbs/shop/[name]/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const route = findRoute(graph.routes, "/shop/:id");
expect(route.parallelSlots[0]).toMatchObject({
name: "breadcrumbs",
pagePath: path.join(appDir, "@breadcrumbs/shop/[name]/page.tsx"),
routeSegments: ["shop", "[name]"],
slotPatternParts: ["shop", ":name"],
slotParamNames: ["name"],
});
});
});
it("captures catch-all slotPatternParts for inherited parallel routes", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "[teamID]/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "[teamID]/sub/folder/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[teamID]/@slot/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[teamID]/@slot/[...catchAll]/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const route = findRoute(graph.routes, "/:teamID/sub/folder");
expect(route.parallelSlots[0]).toMatchObject({
name: "slot",
pagePath: path.join(appDir, "[teamID]/@slot/[...catchAll]/page.tsx"),
routeSegments: ["[...catchAll]"],
slotPatternParts: [":teamID", ":catchAll+"],
slotParamNames: ["teamID", "catchAll"],
});
});
});
it("mirrors when the slot is owned at an intermediate ancestor (not appDir)", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "shop/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "shop/items/detail/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "shop/@sidebar/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "shop/@sidebar/items/detail/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const detail = findRoute(graph.routes, "/shop/items/detail");
expect(detail.parallelSlots[0]).toMatchObject({
name: "sidebar",
pagePath: path.join(appDir, "shop/@sidebar/items/detail/page.tsx"),
defaultPath: path.join(appDir, "shop/@sidebar/default.tsx"),
routeSegments: ["items", "detail"],
slotPatternParts: ["shop", "items", "detail"],
});
});
});
it("rejects page and route handlers that materialize to the same URL", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "dashboard/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "dashboard/route.ts", EMPTY_ROUTE);
await expect(buildAppRouteGraph(appDir, createValidFileMatcher())).rejects.toThrow(
"Conflicting route and page at /dashboard",
);
});
});
it("accepts dynamic segment names with dots and at-signs (Next.js parity)", async () => {
// Next.js PARAMETER_PATTERN accepts any non-] characters inside brackets.
// See: https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/get-dynamic-param.ts
// Note: colon (:) is tested via patternToNextFormat in route-sorting.test.ts
// to avoid NTFS filename issues on Windows.
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "products/[variant.id]/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "users/[user@domain]/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const patterns = graph.routes.map((r) => r.pattern);
expect(patterns).toContain("/products/:variant.id");
expect(patterns).toContain("/users/:user@domain");
});
});
it("accepts catch-all and optional-catch-all segments with broadened param names", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "[...variant.id]/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "blog/[[...user@domain]]/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const patterns = graph.routes.map((r) => r.pattern);
expect(patterns).toContain("/:variant.id+");
expect(patterns).toContain("/blog/:user@domain*");
});
});
it("skips routes whose param names end in + or * (would collide with internal modifiers)", async () => {
// Param names ending in + or * would map to :id+ / :id*, which the trie
// matcher interprets as catch-all / optional-catch-all. Skip these routes
// entirely to avoid ambiguity.
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "[id+]/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[id*]/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const patterns = graph.routes.map((r) => r.pattern);
expect(patterns).not.toContain("/:id+");
expect(patterns).not.toContain("/:id*");
expect(patterns).toHaveLength(0);
});
});
// Intercepting route source-pattern computation. Mirrors Next.js'
// `extractInterceptionRouteInformation` which derives the intercepting
// route from the slot's owner path (route groups + `@slot` segments are
// invisible). The pattern is used at request time to gate `findIntercept`
// against the Next-URL header.
// https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/interception-routes.ts
describe("intercepting routes", () => {
function collectIntercepts(routes: readonly AppRouteGraphRoute[]) {
const out: Array<{
ownerRoute: string;
slotKey: string;
targetPattern: string;
sourceMatchPattern: string;
sourcePageSegments?: string[];
layoutSegments?: string[][];
branchSegments?: string[];
convention: string;
params: string[];
}> = [];
for (const route of routes) {
for (const slot of route.parallelSlots) {
for (const ir of slot.interceptingRoutes) {
out.push({
ownerRoute: route.pattern,
slotKey: slot.key,
targetPattern: ir.targetPattern,
sourceMatchPattern: ir.sourceMatchPattern,
sourcePageSegments: ir.sourcePageSegments,
layoutSegments: ir.layoutSegments,
branchSegments: ir.branchSegments,
convention: ir.convention,
params: ir.params,
});
}
}
}
return out;
}
function collectSiblingIntercepts(routes: readonly AppRouteGraphRoute[]) {
const out: Array<{
ownerRoute: string;
targetPattern: string;
sourceMatchPattern: string;
sourcePageSegments?: string[];
layoutSegments?: string[][];
branchSegments?: 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,
sourcePageSegments: ir.sourcePageSegments,
layoutSegments: ir.layoutSegments,
branchSegments: ir.branchSegments,
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) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "nested/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@slot/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@slot/(.)nested/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const intercepts = collectIntercepts(graph.routes);
expect(intercepts).toContainEqual(
expect.objectContaining({
targetPattern: "/nested",
sourceMatchPattern: "/",
convention: ".",
}),
);
});
});
it("computes target with subdirectory prefix for (.) slot nested in a slot subdirectory", async () => {
// Regression test for issue #1364 Part A.
// When the (.) marker lives inside a subdirectory of the @slot dir, baseParts
// must include the visible segments between appDir and the marker's parent dir,
// not just the routeDir-relative segments (which omit the subdirectory).
//
// Layout:
// app/@modal/sub/(.)target/[id]/page.tsx
// routeDir = app/ (root), but marker parent is app/@modal/sub
// expected targetPattern = /sub/target/:id (not /:id)
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "sub/target/[id]/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@modal/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "@modal/sub/(.)target/[id]/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const intercepts = collectIntercepts(graph.routes);
// sourceMatchPattern derives from interceptParentDir (app/@modal/sub),
// stripping the invisible @modal → remaining visible segment "sub" → "/sub".
expect(intercepts).toContainEqual(
expect.objectContaining({
targetPattern: "/sub/target/:id",
sourceMatchPattern: "/sub",
convention: ".",
}),
);
});
});
it("includes dynamic ancestor params for (.) slot with a dynamic ancestor segment", async () => {
// Regression for the double-conversion bug: raw filesystem segments must be
// passed as baseParts so that [locale] is not converted to :locale before
// the final convertSegmentsToRouteParts call (which would then treat :locale
// as static and drop it from params).
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "[locale]/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[locale]/photos/[id]/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[locale]/@modal/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[locale]/@modal/(.)photos/[id]/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "[locale]/@modal/(.)photos/[id]/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const intercepts = collectIntercepts(graph.routes);
expect(intercepts).toContainEqual(
expect.objectContaining({
targetPattern: "/:locale/photos/:id",
params: ["locale", "id"],
layoutSegments: [["photos", "[id]"]],
branchSegments: ["photos", "[id]"],
convention: ".",
}),
);
});
});
it("computes `/feed` for (..) slot nested under a static segment", async () => {
// Mirrors the (..) marker scoped to a parallel slot: source pathname
// must match the slot's owner directory (`/feed`), and the target
// pattern climbs one segment to `/photos/:id`.
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "photos/[id]/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "feed/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "feed/@modal/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "feed/@modal/(..)photos/[id]/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const intercepts = collectIntercepts(graph.routes);
expect(intercepts).toContainEqual(
expect.objectContaining({
targetPattern: "/photos/:id",
sourceMatchPattern: "/feed",
convention: "..",
}),
);
});
});
it("strips `@modal` and keeps dynamic ancestor segments", async () => {
// Mirrors test/e2e/app-dir/parallel-routes-and-interception-from-root:
// app/[locale]/example/@modal/(...)[locale]/intercepted/page.tsx
// intercepting route = /[locale]/example.
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "[locale]/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "[locale]/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[locale]/example/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "[locale]/example/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[locale]/example/@modal/default.tsx", EMPTY_PAGE);
await writeAppFile(
appDir,
"[locale]/example/@modal/(...)[locale]/intercepted/page.tsx",
EMPTY_PAGE,
);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const intercepts = collectIntercepts(graph.routes);
expect(intercepts).toContainEqual(
expect.objectContaining({
targetPattern: "/:locale/intercepted",
sourceMatchPattern: "/:locale/example",
convention: "...",
}),
);
});
});
it("computes intercepting route across `(..)(..)` two-levels-up marker", async () => {
// Inspired by test/e2e/app-dir/interception-segments-two-levels-above
// but adapted to use a parallel slot, which is the structure vinext
// currently supports for interception markers.
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/@modal/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "foo/bar/@modal/(..)(..)hoge/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const intercepts = collectIntercepts(graph.routes);
// (..)(..) target climbs two visible segments from /foo/bar → /,
// then appends `hoge`. Intercepting route remains /foo/bar.
expect(intercepts).toContainEqual(
expect.objectContaining({
targetPattern: "/hoge",
sourceMatchPattern: "/foo/bar",
sourcePageSegments: ["foo", "bar", "@modal", "(..)(..)hoge"],
convention: "../..",
}),
);
});
});
it("promotes dynamic interception topology into RouteManifest facts", async () => {
await withTempApp(async (appDir) => {
await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "[locale]/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "[locale]/feed/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "[locale]/feed/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[locale]/photos/[id]/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[locale]/feed/@modal/default.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "[locale]/feed/@modal/(..)photos/[id]/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const interceptions = Array.from(graph.routeManifest.segmentGraph.interceptions.values());
const interceptionsBySlotId = Array.from(
graph.routeManifest.segmentGraph.interceptionsBySlotId.entries(),
);
expect(interceptions).toEqual([
{
id: "interception:slot:modal:/[locale]/feed:/:locale/feed->/:locale/photos/:id",
sourcePattern: "/:locale/feed",
sourcePatternParts: [":locale", "feed"],
targetPattern: "/:locale/photos/:id",
targetPatternParts: [":locale", "photos", ":id"],
slotId: "slot:modal:/[locale]/feed",
ownerLayoutId: "layout:/[locale]/feed",
interceptingRouteId: "route:/:locale/feed",
targetRouteId: "route:/:locale/photos/:id",
},
]);
expect(interceptionsBySlotId).toEqual([["slot:modal:/[locale]/feed", interceptions]]);
});
});
it("registers `(..)` sibling interception for showcase catchall outside a parallel slot", async () => {
// Ported from Next.js: test/e2e/app-dir/interception-routes-multiple-catchall
// 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);
await writeAppFile(appDir, "showcase/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "templates/layout.tsx", EMPTY_LAYOUT);
await writeAppFile(appDir, "templates/[...catchAll]/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "templates/(..)showcase/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "templates/(..)showcase/[...catchAll]/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const patterns = graph.routes.map((route) => route.pattern);
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("registers `(..)(..)` sibling interception outside a parallel slot", async () => {
// Ported from Next.js: test/e2e/app-dir/interception-segments-two-levels-above
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 patterns = graph.routes.map((route) => route.pattern);
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("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);
await writeAppFile(appDir, "gallery/photo/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "gallery/(.)photo/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const patterns = graph.routes.map((route) => route.pattern);
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("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);
await writeAppFile(appDir, "target/page.tsx", EMPTY_PAGE);
await writeAppFile(appDir, "deep/path/(...)target/page.tsx", EMPTY_PAGE);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const patterns = graph.routes.map((route) => route.pattern);
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("/");
});
});
describe("findOwnerRouteForDir with Windows-style separators", () => {
// On Windows the config hook normalizes `appDir` to forward slashes,
// but marker directories and route file paths descend through native
// path.join/path.dirname and stay backslash. findOwnerRouteForDir must
// compare in forward-slash space; CI is POSIX-only, so simulate the
// Windows shapes directly.
function makeRoute(pagePath: string, patternParts: string[]): AppRouteGraphRoute {
return { pagePath, routePath: null, patternParts } as unknown as AppRouteGraphRoute;
}
const appDir = "C:/proj/app";
const rootRoute = makeRoute("C:\\proj\\app\\page.tsx", []);
const templatesRoute = makeRoute("C:\\proj\\app\\templates\\page.tsx", ["templates"]);
const routes = [rootRoute, templatesRoute];
const routesByDir = new Map([
["C:/proj/app", rootRoute],
["C:/proj/app/templates", templatesRoute],
]);
it("terminates the ancestor walk at the forward-slash app root", () => {
// deep/path has no route — the walk must stop at appDir and attach to
// the nearest ancestor route instead of overshooting the app root.
const owner = findOwnerRouteForDir(
"C:\\proj\\app\\deep\\path",
appDir,
routes,
routesByDir,
);
expect(owner).toBe(rootRoute);
});
it("finds the exact owner for a backslash marker parent dir", () => {
const owner = findOwnerRouteForDir("C:\\proj\\app\\templates", appDir, routes, routesByDir);
expect(owner).toBe(templatesRoute);
});
it("matches catch-all subtree routes across separator styles", () => {
const catchAll = makeRoute("C:\\proj\\app\\templates\\[...slug]\\page.tsx", [
"templates",
":slug+",
]);
const owner = findOwnerRouteForDir(
"C:\\proj\\app\\templates",
appDir,
[catchAll],
new Map([["C:/proj/app/templates/[...slug]", catchAll]]),
);
expect(owner).toBe(catchAll);
});
it("resolves the root owner when the marker parent is the app root itself", () => {
const owner = findOwnerRouteForDir(appDir, appDir, routes, routesByDir);
expect(owner).toBe(rootRoute);
});
});
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",
});
});
});
});
});