Files
cloudflare__vinext/tests/app-route-graph.test.ts
T
James Anderson 2cf21433cb fix(routing): mirror inherited parallel slots to descendant sub-pages (#1039)
* fix(routing): mirror inherited parallel slots to descendant sub-pages

discoverInheritedParallelSlots was hard-coding pagePath to null for any
ancestor slot, so inherited slots only ever rendered default.tsx — even
when an obvious mirror like @breadcrumbs/about/page.tsx existed for the
matching route. Track each ancestor's segment index, then look up a
mirror via either a literal filesystem match or, when route groups appear
on either side, by URL-parts comparison through convertSegmentsToRouteParts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(routing): extract slot-specific params for inherited mirrors

When an inherited parallel slot's mirrored sub-page declares dynamic
markers under different names than the route's (e.g. slot uses
@breadcrumbs/distinct/[name] for a route at distinct/[id]), the runtime
was passing the route's matchedParams through unchanged — so the slot
page received {id} instead of {name} and rendered with an empty value.

Three pieces:

* `findMirroredSlotPage` now also accepts pattern-compatible matches and
  returns the slot's URL parts and param names. Specificity scoring
  picks the most-specific compatible sub-page (literal > dynamic >
  catch-all).
* Each ParallelSlot carries optional `slotPatternParts` and
  `slotParamNames`, serialized through the manifest.
* `buildPageElements` extracts a per-request `slotOverrides[key].params`
  by matching the request URL against `slotPatternParts` whenever a
  slot's param names aren't a subset of the route's. `pageModule` on
  `AppPageSlotOverride` becomes optional so a params-only override
  doesn't have to swap the slot's page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(routing): address bonk review on inherited parallel slot mirror

* Swap inverted scoring for required vs optional catch-alls — `:name+`
  (matches ≥1 segments) is more constrained than `:name*` (≥0), so it
  now scores higher, preventing a less-specific optional catch-all from
  shadowing a required one when both compete for the same route.
* Tighten findMirroredSlotPage docstring: the implementation collapses
  exact URL-parts equality and pattern compatibility into one scored
  loop, where literals naturally win. Rewrite the comment to reflect
  what the code actually does.
* Document patternsCompatible's conservative rejection of literal slot
  segments paired with dynamic route segments (and the consequence that
  a literal slot sub-page never matches a catch-all route).
* Note in buildSlotOverrides why an empty slotParamNames also skips the
  override (no dynamic markers means nothing to extract).
* Add unit tests for two cases that were only covered indirectly:
  graph-level slotPatternParts/slotParamNames when route and slot use
  different param names, and a slot owned at an intermediate ancestor
  rather than appDir.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:02:20 +01:00

281 lines
12 KiB
TypeScript

import { describe, it, expect } 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,
type AppRoute,
} from "../packages/vinext/src/routing/app-route-graph.js";
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: AppRoute[], pattern: string): AppRoute {
const route = routes.find((candidate) => candidate.pattern === pattern);
if (!route) {
throw new Error(`Expected route ${pattern} to be materialized`);
}
return route;
}
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"),
});
});
});
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"],
});
});
});
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("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("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",
);
});
});
});