mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
dabcb55e63
* fix(app-router): expose active source page on window.next App Router pages did not update window.next.__internal_src_page, so Next.js-compatible tooling and deploy tests could not observe the active source page on initial render or client transitions. The payload only exposed URL route IDs, which drop route groups and bracketed dynamic segment names. The fix carries a source-page metadata field from the app route's filesystem segments, reads it from committed browser router state, and mirrors it onto window.next like Next.js does. Adds regression coverage for route-group and dynamic source-page encoding plus the window.next writer contract ported from the upstream app-dir test. * fix(app-router): preserve source page across boundary renders * fix(app-router): tolerate malformed source page metadata * fix(app-router): preserve intercepted fallback source page * fix(app-router): omit unknown boundary source page --------- Co-authored-by: James <james@eli.cx>
1705 lines
76 KiB
TypeScript
1705 lines
76 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"],
|
|
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, "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);
|
|
|
|
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"));
|
|
|
|
// 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"));
|
|
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"),
|
|
});
|
|
});
|
|
});
|
|
|
|
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)", () => {
|
|
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/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"));
|
|
|
|
// 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: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
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/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"),
|
|
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[];
|
|
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,
|
|
convention: ir.convention,
|
|
params: ir.params,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function collectSiblingIntercepts(routes: readonly AppRouteGraphRoute[]) {
|
|
const out: Array<{
|
|
ownerRoute: string;
|
|
targetPattern: string;
|
|
sourceMatchPattern: string;
|
|
sourcePageSegments?: 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,
|
|
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]/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"],
|
|
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",
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|