diff --git a/packages/docs/src/pages/learn/FileSystemRouting.mdx b/packages/docs/src/pages/learn/FileSystemRouting.mdx index 49f4765..fdfe577 100644 --- a/packages/docs/src/pages/learn/FileSystemRouting.mdx +++ b/packages/docs/src/pages/learn/FileSystemRouting.mdx @@ -126,6 +126,8 @@ A dynamic route **must** export `generateStaticParams`; the build fails otherwis Param values must be non-empty strings that stay within their URL segment: a regular param value must not contain `/`, and no value may contain `.` or `..` segments, `?`, or `#`. A catch-all param value may contain `/` to span multiple segments, but not leading, trailing, or repeated slashes. The build fails on any other value, since it would generate a page that its own route can never match (or a file outside the output directory). +If `generateStaticParams` returns the same params more than once, the duplicates are collapsed and the page is generated once. However, if two _different_ pages generate the same URL — such as a static `blog/hello/page.tsx` next to a `blog/[slug]/page.tsx` whose `generateStaticParams` also returns `{ slug: "hello" }` — the build fails: the two pages would fight over one output file, and route precedence makes one of them unreachable. + Because `generateStaticParams` runs on the server at build time, a page module that exports it cannot be marked `"use client"`. If the page body needs to be a Client Component, move it into a separate `"use client"` module and re-export it from the page: ```tsx diff --git a/packages/static/src/fs-routes/entries.tsx b/packages/static/src/fs-routes/entries.tsx index 0c2918e..01876bf 100644 --- a/packages/static/src/fs-routes/entries.tsx +++ b/packages/static/src/fs-routes/entries.tsx @@ -1,7 +1,5 @@ import { FsRouteSlot } from "#rsc-client"; -import { rscPayloadDir } from "virtual:funstack/config"; -import { deferRegistry } from "../rsc/defer"; -import { getPayloadIDFor } from "../rsc/rscModule"; +import { registerDeferredPayload } from "../rsc/defer"; import type { GetEntriesResult } from "../entryDefinition"; import { createFsRoutesEntriesWithHost, @@ -16,11 +14,7 @@ import { * render through the `FsRouteSlot` client reference. */ const rscRuntimeHost: FsRoutesRuntimeHost = { - registerChunk(element, name) { - const id = getPayloadIDFor(crypto.randomUUID(), rscPayloadDir); - deferRegistry.register(element, id, name); - return id; - }, + registerChunk: registerDeferredPayload, RouteSlot: FsRouteSlot, }; diff --git a/packages/static/src/fs-routes/runtime.tsx b/packages/static/src/fs-routes/runtime.tsx index f498a42..d9c0987 100644 --- a/packages/static/src/fs-routes/runtime.tsx +++ b/packages/static/src/fs-routes/runtime.tsx @@ -1,4 +1,9 @@ -import { createElement, type ComponentType, type ReactElement } from "react"; +import { + createElement, + type ComponentType, + type ReactElement, + type ReactNode, +} from "react"; import { Router } from "@funstack/router"; import type { RouteDefinition } from "@funstack/router/server"; import type { @@ -227,7 +232,7 @@ export function createFsRoutesEntriesWithHost( const definition: { id: string; path?: string; - component?: React.ComponentType | React.ReactNode; + component?: ComponentType | ReactNode; children?: RouteDefinition[]; } = { id: meta.id }; if (node.path !== undefined) { @@ -239,7 +244,7 @@ export function createFsRoutesEntriesWithHost( // the router can render it in the browser. Pass the component // itself so it receives the params of the current match, keeping // them live across soft client-side navigation. - definition.component = Component as React.ComponentType; + definition.component = Component as ComponentType; } else { // A Server Component crosses the RSC boundary only as its rendered // output, so a client slot stands in for it: it renders the @@ -283,7 +288,7 @@ export function createFsRoutesEntriesWithHost( tree: FsRouteTreeNode[]; metas: Map; page: StaticPage; - }): React.ReactNode { + }): ReactNode { const routes = buildRouteDefinitions( tree, metas, diff --git a/packages/static/src/fs-routes/tree.test.ts b/packages/static/src/fs-routes/tree.test.ts index cc4c281..27ff352 100644 --- a/packages/static/src/fs-routes/tree.test.ts +++ b/packages/static/src/fs-routes/tree.test.ts @@ -244,6 +244,55 @@ describe("collectStaticPaths", () => { await expect(collectStaticPaths(tree)).rejects.toThrow(/slug/); }); + it("dedupes duplicate params returned by generateStaticParams", async () => { + const tree: FsRouteTreeNode[] = [ + { + path: "/blog/:slug", + page: true, + module: pageModule(() => [ + { slug: "hello" }, + { slug: "hello" }, + { slug: "world" }, + ]), + }, + ]; + const pages = await collectStaticPaths(tree); + expect(withoutChain(pages)).toEqual([ + { urlPath: "/blog/hello", params: { slug: "hello" } }, + { urlPath: "/blog/world", params: { slug: "world" } }, + ]); + }); + + it("throws when two different routes generate the same URL", async () => { + const tree: FsRouteTreeNode[] = [ + { + path: "/blog/hello", + page: true, + module: component, + filePath: "blog/hello/page.tsx", + }, + { + path: "/blog/:slug", + page: true, + module: pageModule(() => [{ slug: "hello" }]), + filePath: "blog/[slug]/page.tsx", + }, + ]; + await expect(collectStaticPaths(tree)).rejects.toThrow( + /\("blog\/hello\/page\.tsx" and "blog\/\[slug\]\/page\.tsx"\) generate the same URL "\/blog\/hello"/, + ); + }); + + it("describes a conflicting page by its route path when it has no file", async () => { + const tree: FsRouteTreeNode[] = [ + { path: "/about", page: true, module: component }, + { path: "/about", page: true, module: component }, + ]; + await expect(collectStaticPaths(tree)).rejects.toThrow( + /\(route "\/about" and route "\/about"\) generate the same URL "\/about"/, + ); + }); + it("allows a client component page on a static route", async () => { const tree: FsRouteTreeNode[] = [ { diff --git a/packages/static/src/fs-routes/tree.ts b/packages/static/src/fs-routes/tree.ts index 6166887..b64dd89 100644 --- a/packages/static/src/fs-routes/tree.ts +++ b/packages/static/src/fs-routes/tree.ts @@ -237,6 +237,15 @@ async function walk( } } +/** + * Formats a page node for a URL-collision error message. + */ +function describePage(node: FsRouteTreeNode): string { + return node.filePath !== undefined + ? `"${node.filePath}"` + : `route "${node.path ?? "(pathless)"}"`; +} + /** * Walks a route tree and enumerates every page to statically generate. * @@ -245,13 +254,42 @@ async function walk( * `generateStaticParams()`; a dynamic route without that export fails the * build, since a static site cannot serve pages that were not enumerated at * build time. + * + * Duplicate params returned by one `generateStaticParams()` are collapsed + * into a single page. Two *different* routes generating the same URL (e.g. a + * static page next to a dynamic sibling whose params resolve to it) fail the + * build: the pages would fight over one output file, and route precedence + * makes one of them unreachable. */ export async function collectStaticPaths( tree: FsRouteTreeNode[], ): Promise { const pages: StaticPage[] = []; await walk(tree, [], [], pages); - return pages; + const byUrl = new Map(); + const deduped: StaticPage[] = []; + for (const page of pages) { + const existing = byUrl.get(page.urlPath); + if (existing === undefined) { + byUrl.set(page.urlPath, page); + deduped.push(page); + continue; + } + const existingLeaf = existing.chain[existing.chain.length - 1]!; + const leaf = page.chain[page.chain.length - 1]!; + if (existingLeaf === leaf) { + // The same page enumerated twice (generateStaticParams() returned + // duplicate params); the pages would be identical, so keep the first. + continue; + } + throw new Error( + `Two pages (${describePage(existingLeaf)} and ${describePage(leaf)}) ` + + `generate the same URL "${page.urlPath}". A URL can be generated by ` + + `only one page; remove the conflicting value from ` + + `generateStaticParams() or delete one of the pages.`, + ); + } + return deduped; } /** diff --git a/packages/static/src/fs-routes/types.ts b/packages/static/src/fs-routes/types.ts index 674d51f..5edac71 100644 --- a/packages/static/src/fs-routes/types.ts +++ b/packages/static/src/fs-routes/types.ts @@ -75,6 +75,10 @@ export interface FsRouteModule { * `#`. Only a catch-all segment's value may contain slashes (but not * leading, trailing, or repeated ones). The build fails on any other * value, which would generate a page its own route cannot match. + * + * Entries resolving to the same URL are deduplicated. A value resolving to + * a URL that a *different* page also generates (e.g. a static sibling + * route) fails the build instead. */ generateStaticParams?: () => MaybePromise>>; [key: string]: unknown; diff --git a/packages/static/src/rsc/defer.tsx b/packages/static/src/rsc/defer.tsx index 1626861..f255a68 100644 --- a/packages/static/src/rsc/defer.tsx +++ b/packages/static/src/rsc/defer.tsx @@ -33,6 +33,25 @@ export const deferRegistry = new DeferRegistry((element) => renderToReadableStream(element), ); +/** + * Registers a Server Component element as a separate RSC payload in the + * shared defer registry and returns the payload ID under which it is served. + * The sanitized `name` is included in the dev payload file name for + * debugging. + */ +export function registerDeferredPayload( + element: ReactElement, + name?: string, +): string { + const sanitizedName = name ? sanitizeName(name) : undefined; + const rawId = sanitizedName + ? `${sanitizedName}-${crypto.randomUUID()}` + : crypto.randomUUID(); + const id = getPayloadIDFor(rawId, rscPayloadDir); + deferRegistry.register(element, id, name); + return id; +} + /** * Renders given Server Component into a separate RSC payload. * @@ -47,13 +66,6 @@ export function defer( element: ReactElement, options?: DeferOptions, ): ReactNode { - const name = options?.name; - const sanitizedName = name ? sanitizeName(name) : undefined; - const rawId = sanitizedName - ? `${sanitizedName}-${crypto.randomUUID()}` - : crypto.randomUUID(); - const id = getPayloadIDFor(rawId, rscPayloadDir); - deferRegistry.register(element, id, name); - + const id = registerDeferredPayload(element, options?.name); return ; }