From 4b796d225bc65ee744d5f5ad438e79eb00de98c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 00:55:26 +0000 Subject: [PATCH] fix: route precedence and conflict validation in fs-routes next adapter FUNSTACK Router matches routes in definition order (first match wins), but the next adapter ordered sibling routes alphabetically, so dynamic ([slug]) and catch-all ([...slug]) segments were emitted before static siblings and shadowed them. Sibling routes are now ordered by specificity: static before dynamic before catch-all. Pathless layouts (layouts in route groups) are ranked by their greediest descendants. The adapter also fails loudly instead of silently producing broken routes for: - optional catch-all segments ([[...param]]) - parallel route slots (@slot) and intercepting routes ((.)segment) - two pages resolving to the same route (e.g. via route groups, or sibling dynamic pages with different param names) - duplicate page/layout files in one directory (page.tsx + page.jsx) Multiple root layouts via route groups remain supported. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138kFvyLCb2rhkZvC6sD2Bq --- .../src/pages/learn/FileSystemRouting.mdx | 15 ++ .../src/pages/blog/featured/page.tsx | 8 + .../static/e2e/tests-dev/fs-routing.spec.ts | 7 + packages/static/e2e/tests/fs-routing.spec.ts | 8 + .../static/src/fs-routes/nextAdapter.test.ts | 152 ++++++++++++++++++ packages/static/src/fs-routes/nextAdapter.ts | 124 ++++++++++++++ 6 files changed, 314 insertions(+) create mode 100644 packages/static/e2e/fixture-fs-routing/src/pages/blog/featured/page.tsx diff --git a/packages/docs/src/pages/learn/FileSystemRouting.mdx b/packages/docs/src/pages/learn/FileSystemRouting.mdx index 19fa6ca..4e1a8eb 100644 --- a/packages/docs/src/pages/learn/FileSystemRouting.mdx +++ b/packages/docs/src/pages/learn/FileSystemRouting.mdx @@ -67,6 +67,21 @@ The built-in adapter follows Next.js App-Router conventions: Files that are not named `page` or `layout` are ignored, so helpers and components can be co-located with routes. +### Route Precedence + +Sibling routes are matched by specificity: **static segments match before dynamic segments, which match before catch-alls**. For example, with both `blog/featured/page.tsx` and `blog/[slug]/page.tsx`, the URL `/blog/featured` renders the static page, and any other `/blog/…` URL falls through to the dynamic one. + +### Unsupported Syntaxes and Conflicts + +The adapter fails the build with a clear error instead of producing broken routes when it encounters: + +- **Optional catch-all segments** (`[[...param]]`) — use a catch-all (`[...param]`) plus a separate `page.tsx` for the parent route instead. +- **Parallel route slots** (`@slot`) and **intercepting routes** (`(.)segment`) — these Next.js features are not supported. +- **Conflicting pages** — two pages resolving to the same route, such as `(a)/foo/page.tsx` + `(b)/foo/page.tsx`, or sibling dynamic pages with different param names (`[a]` + `[b]`). +- **Duplicate files** — two page (or layout) files in the same directory, such as `page.tsx` next to `page.jsx`. + +Multiple root layouts via route groups (e.g. `(marketing)/layout.tsx` and `(shop)/layout.tsx`) are supported. + ```tsx // src/pages/page.tsx export default function Home() { diff --git a/packages/static/e2e/fixture-fs-routing/src/pages/blog/featured/page.tsx b/packages/static/e2e/fixture-fs-routing/src/pages/blog/featured/page.tsx new file mode 100644 index 0000000..b8d0fd8 --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing/src/pages/blog/featured/page.tsx @@ -0,0 +1,8 @@ +export default function FeaturedPosts() { + return ( +
+

Featured Posts

+

blog-featured

+
+ ); +} diff --git a/packages/static/e2e/tests-dev/fs-routing.spec.ts b/packages/static/e2e/tests-dev/fs-routing.spec.ts index 2a6aa85..57227e4 100644 --- a/packages/static/e2e/tests-dev/fs-routing.spec.ts +++ b/packages/static/e2e/tests-dev/fs-routing.spec.ts @@ -16,6 +16,13 @@ test.describe("File-system routing (dev server)", () => { await expect(page.getByTestId("slug")).toHaveText("hello"); }); + test("renders a static page instead of a dynamic sibling route", async ({ + page, + }) => { + await page.goto("/blog/featured"); + await expect(page.getByTestId("page-id")).toHaveText("blog-featured"); + }); + test("wraps nested pages in their layout", async ({ page }) => { await page.goto("/dashboard/settings"); await expect(page.getByTestId("dashboard-layout")).toHaveText( diff --git a/packages/static/e2e/tests/fs-routing.spec.ts b/packages/static/e2e/tests/fs-routing.spec.ts index ded2f3c..2b128ac 100644 --- a/packages/static/e2e/tests/fs-routing.spec.ts +++ b/packages/static/e2e/tests/fs-routing.spec.ts @@ -6,6 +6,7 @@ test.describe("File-system routing build output", () => { "/", "/about", "/blog", + "/blog/featured", "/blog/hello", "/blog/world", "/dashboard", @@ -36,6 +37,13 @@ test.describe("File-system routing rendering", () => { await expect(page.getByTestId("page-id")).toHaveText("blog-index"); }); + test("renders a static page instead of a dynamic sibling route", async ({ + page, + }) => { + await page.goto("/blog/featured"); + await expect(page.getByTestId("page-id")).toHaveText("blog-featured"); + }); + test("statically generates dynamic routes with params", async ({ page }) => { await page.goto("/blog/hello"); await expect(page.getByTestId("page-id")).toHaveText("blog-post"); diff --git a/packages/static/src/fs-routes/nextAdapter.test.ts b/packages/static/src/fs-routes/nextAdapter.test.ts index eaf7220..c4f6a7f 100644 --- a/packages/static/src/fs-routes/nextAdapter.test.ts +++ b/packages/static/src/fs-routes/nextAdapter.test.ts @@ -110,6 +110,158 @@ describe("nextRoutes adapter", () => { ]); }); + it("orders static routes before dynamic siblings", () => { + const adapter = nextRoutes(); + const tree = adapter.buildRoutes( + makeFiles(["blog/[slug]/page.tsx", "blog/about/page.tsx"]), + ); + expect(simplify(tree)).toEqual([ + { path: "/blog/about", page: true, id: "blog/about/page.tsx" }, + { path: "/blog/:slug", page: true, id: "blog/[slug]/page.tsx" }, + ]); + }); + + it("orders static and dynamic routes before catch-all siblings", () => { + const adapter = nextRoutes(); + const tree = adapter.buildRoutes( + makeFiles([ + "docs/[...slug]/page.tsx", + "docs/[version]/page.tsx", + "docs/intro/page.tsx", + ]), + ); + expect(simplify(tree)).toEqual([ + { path: "/docs/intro", page: true, id: "docs/intro/page.tsx" }, + { path: "/docs/:version", page: true, id: "docs/[version]/page.tsx" }, + { path: "/docs/:slug*", page: true, id: "docs/[...slug]/page.tsx" }, + ]); + }); + + it("orders routes by specificity inside a layout", () => { + const adapter = nextRoutes(); + const tree = adapter.buildRoutes( + makeFiles([ + "blog/layout.tsx", + "blog/[slug]/page.tsx", + "blog/archive/page.tsx", + "blog/page.tsx", + ]), + ); + expect(simplify(tree)).toEqual([ + { + path: "/blog", + page: false, + id: "blog/layout.tsx", + children: [ + { path: "/", page: true, id: "blog/page.tsx" }, + { path: "/archive", page: true, id: "blog/archive/page.tsx" }, + { path: "/:slug", page: true, id: "blog/[slug]/page.tsx" }, + ], + }, + ]); + }); + + it("orders a grouped layout with dynamic children after static siblings", () => { + const adapter = nextRoutes(); + const tree = adapter.buildRoutes( + makeFiles([ + "(app)/layout.tsx", + "(app)/[slug]/page.tsx", + "about/page.tsx", + ]), + ); + expect(simplify(tree)).toEqual([ + { path: "/about", page: true, id: "about/page.tsx" }, + { + path: undefined, + page: false, + id: "(app)/layout.tsx", + children: [{ path: "/:slug", page: true, id: "(app)/[slug]/page.tsx" }], + }, + ]); + }); + + it("rejects optional catch-all segments", () => { + const adapter = nextRoutes(); + expect(() => + adapter.buildRoutes(makeFiles(["docs/[[...slug]]/page.tsx"])), + ).toThrow(/Optional catch-all segments/); + }); + + it("rejects parallel route slots", () => { + const adapter = nextRoutes(); + expect(() => adapter.buildRoutes(makeFiles(["@modal/page.tsx"]))).toThrow( + /Parallel route slots/, + ); + }); + + it("rejects intercepting routes", () => { + const adapter = nextRoutes(); + expect(() => + adapter.buildRoutes(makeFiles(["feed/(..)photo/page.tsx"])), + ).toThrow(/Intercepting routes/); + }); + + it("rejects two pages resolving to the same route via route groups", () => { + const adapter = nextRoutes(); + expect(() => + adapter.buildRoutes(makeFiles(["(a)/foo/page.tsx", "(b)/foo/page.tsx"])), + ).toThrow(/resolve to the same route/); + }); + + it("rejects sibling dynamic pages with different param names", () => { + const adapter = nextRoutes(); + expect(() => + adapter.buildRoutes( + makeFiles(["blog/[a]/page.tsx", "blog/[b]/page.tsx"]), + ), + ).toThrow(/resolve to the same route/); + }); + + it("rejects duplicate page files in one directory", () => { + const adapter = nextRoutes(); + expect(() => + adapter.buildRoutes(makeFiles(["about/page.tsx", "about/page.jsx"])), + ).toThrow(/Duplicate page files/); + }); + + it("allows multiple root layouts via route groups", () => { + const adapter = nextRoutes(); + const tree = adapter.buildRoutes( + makeFiles([ + "(marketing)/layout.tsx", + "(marketing)/page.tsx", + "(shop)/layout.tsx", + "(shop)/cart/page.tsx", + ]), + ); + expect(simplify(tree)).toEqual([ + { + path: undefined, + page: false, + id: "(marketing)/layout.tsx", + children: [{ path: "/", page: true, id: "(marketing)/page.tsx" }], + }, + { + path: undefined, + page: false, + id: "(shop)/layout.tsx", + children: [{ path: "/cart", page: true, id: "(shop)/cart/page.tsx" }], + }, + ]); + }); + + it("allows a dynamic page next to a catch-all sibling", () => { + const adapter = nextRoutes(); + const tree = adapter.buildRoutes( + makeFiles(["docs/[page]/page.tsx", "docs/[...rest]/page.tsx"]), + ); + expect(simplify(tree)).toEqual([ + { path: "/docs/:page", page: true, id: "docs/[page]/page.tsx" }, + { path: "/docs/:rest*", page: true, id: "docs/[...rest]/page.tsx" }, + ]); + }); + it("honours custom page/layout file names", () => { const adapter = nextRoutes({ pageFileName: "index", diff --git a/packages/static/src/fs-routes/nextAdapter.ts b/packages/static/src/fs-routes/nextAdapter.ts index 19bd4c1..f9e5cb5 100644 --- a/packages/static/src/fs-routes/nextAdapter.ts +++ b/packages/static/src/fs-routes/nextAdapter.ts @@ -55,6 +55,29 @@ function classify( return undefined; } +/** + * Rejects directory segments using Next.js syntaxes that this adapter does not + * support, so they fail loudly instead of silently producing broken routes. + */ +function validateSegment(segment: string, filePath: string): void { + if (/^\[\[.*\]\]$/.test(segment)) { + throw new Error( + `Optional catch-all segments ("${segment}" in "${filePath}") are not supported. ` + + `Use a catch-all segment ([...param]) plus a separate page for the parent route instead.`, + ); + } + if (segment.startsWith("@")) { + throw new Error( + `Parallel route slots ("${segment}" in "${filePath}") are not supported.`, + ); + } + if (/^\(\.{1,3}\)/.test(segment)) { + throw new Error( + `Intercepting routes ("${segment}" in "${filePath}") are not supported.`, + ); + } +} + /** * Converts a directory segment to its URL contribution in FUNSTACK Router * syntax, or `null` when the segment does not affect the URL. @@ -75,6 +98,52 @@ function urlSegment(segment: string): string | null { return segment; } +/** + * Matching specificity of a router URL segment: static segments match before + * dynamic ones, which match before catch-alls. + */ +function segmentRank(segment: string): number { + if (!segment.startsWith(":")) return 0; + return segment.endsWith("*") ? 2 : 1; +} + +/** + * Per-segment specificity ranks of a route node, used to order sibling routes. + * + * A pathless layout (a layout in a route group) consumes no pathname itself, + * so it is ranked by its greediest descendants: the element-wise maximum of + * its children's rank vectors. + */ +function rankVector(node: FsRouteTreeNode): number[] { + if (node.path !== undefined) { + return node.path.split("/").filter(Boolean).map(segmentRank); + } + const vectors = (node.children ?? []).map(rankVector); + const length = Math.max(0, ...vectors.map((vector) => vector.length)); + const result: number[] = []; + for (let i = 0; i < length; i++) { + result.push(Math.max(0, ...vectors.map((vector) => vector[i] ?? 0))); + } + return result; +} + +/** + * Orders sibling routes so that more specific routes match first: FUNSTACK + * Router matches routes in definition order (first match wins), so a dynamic + * or catch-all route emitted before a static sibling would shadow it. + * + * The sort is stable; equally-ranked siblings keep their alphabetical order. + */ +function compareNodes(a: FsRouteTreeNode, b: FsRouteTreeNode): number { + const rankA = rankVector(a); + const rankB = rankVector(b); + const length = Math.min(rankA.length, rankB.length); + for (let i = 0; i < length; i++) { + if (rankA[i] !== rankB[i]) return rankA[i]! - rankB[i]!; + } + return rankA.length - rankB.length; +} + function ensureDir(root: TrieNode, dirs: string[]): TrieNode { let current = root; for (const segment of dirs) { @@ -114,6 +183,7 @@ function emit(node: TrieNode, prefix: string[]): FsRouteTreeNode[] { for (const child of childNodes) { children.push(...emit(child, [])); } + children.sort(compareNodes); const path = here.length === 0 ? undefined : `/${here.join("/")}`; return [{ path, module: node.layout, page: false, children }]; } @@ -126,6 +196,7 @@ function emit(node: TrieNode, prefix: string[]): FsRouteTreeNode[] { for (const child of childNodes) { result.push(...emit(child, here)); } + result.sort(compareNodes); return result; } @@ -143,6 +214,15 @@ function emit(node: TrieNode, prefix: string[]): FsRouteTreeNode[] { * Other files in the routes directory are ignored, so helpers and components * may be co-located with routes. * + * Sibling routes are ordered by specificity — static segments match before + * dynamic segments, which match before catch-alls — so a dynamic route never + * shadows a static sibling. + * + * `buildRoutes` throws on unsupported Next.js syntaxes (optional catch-all + * `[[...param]]`, parallel route slots `@slot`, intercepting routes + * `(.)segment`) and on conflicting route files (two pages resolving to the + * same route, or duplicate page/layout files in one directory). + * * @experimental File-system routing is experimental and not yet subject to * semantic versioning. */ @@ -154,10 +234,54 @@ export function nextRoutes(options: NextRoutesOptions = {}): FsRoutesAdapter { name: "next", buildRoutes(files: FsRouteFile[]): FsRouteTreeNode[] { const root: TrieNode = { segment: "", children: new Map() }; + // Route position each page/layout occupies, with dynamic segments + // normalized so that e.g. `[a]` and `[b]` at the same position conflict. + // Exact directory each page/layout file lives in, to detect duplicate + // files for the same node (e.g. `page.tsx` next to `page.jsx`). + const filesByDir = new Map(); + // Route position of each page, with dynamic segments normalized so that + // e.g. `[a]` and `[b]` pages at the same position conflict. Layouts are + // exempt: multiple layouts at one position via route groups are valid + // (e.g. `(marketing)/layout.tsx` and `(shop)/layout.tsx`). + const pagePositions = new Map(); for (const file of files) { const { dirs, base } = splitFilePath(file.filePath); const kind = classify(base, pageFileName, layoutFileName); if (!kind) continue; + for (const segment of dirs) { + validateSegment(segment, file.filePath); + } + const dirKey = `${kind} ${dirs.join("/")}`; + const sameDir = filesByDir.get(dirKey); + if (sameDir !== undefined) { + throw new Error( + `Duplicate ${kind} files "${sameDir}" and "${file.filePath}": ` + + `a directory may contain only one ${kind} file.`, + ); + } + filesByDir.set(dirKey, file.filePath); + if (kind === "page") { + const position = dirs + .map(urlSegment) + .filter((segment) => segment !== null) + .map((segment) => + segment.startsWith(":") + ? segment.endsWith("*") + ? "[...]" + : "[]" + : segment, + ) + .join("/"); + const conflicting = pagePositions.get(position); + if (conflicting !== undefined) { + throw new Error( + `Route files "${conflicting}" and "${file.filePath}" conflict: ` + + `they resolve to the same route. Routes are matched first-match-wins, ` + + `so one of the pages would never be reachable.`, + ); + } + pagePositions.set(position, file.filePath); + } const node = ensureDir(root, dirs); if (kind === "page") { node.page = file.module;