diff --git a/packages/docs/src/pages/learn/FileSystemRouting.mdx b/packages/docs/src/pages/learn/FileSystemRouting.mdx index 4c3f752..11daafb 100644 --- a/packages/docs/src/pages/learn/FileSystemRouting.mdx +++ b/packages/docs/src/pages/learn/FileSystemRouting.mdx @@ -122,6 +122,16 @@ This generates `blog/hello.html` and `blog/world.html`. Each page component rece A dynamic route **must** export `generateStaticParams`; the build fails otherwise. A static site can only serve pages that were enumerated at build time, so a dynamic route without it would produce no output. +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 +// src/pages/blog/[slug]/page.tsx (a Server Component) +export function generateStaticParams() { + return [{ slug: "hello" }, { slug: "world" }]; +} +export { default } from "./_page"; // _page.tsx is marked "use client" +``` + > **Note:** Because static hosting serves one pre-rendered RSC payload per page, soft client-side navigation between different values of the _same_ dynamic route reflects the params of the initially-loaded page. Loading a dynamic URL directly always renders the correct params. Static routes and layouts navigate fully on the client. ## Custom Conventions (Adapters) diff --git a/packages/static/src/fs-routes/nextAdapter.test.ts b/packages/static/src/fs-routes/nextAdapter.test.ts index c4f6a7f..e8646c0 100644 --- a/packages/static/src/fs-routes/nextAdapter.test.ts +++ b/packages/static/src/fs-routes/nextAdapter.test.ts @@ -262,6 +262,17 @@ describe("nextRoutes adapter", () => { ]); }); + it("records the source file path on each node", () => { + const tree = nextRoutes().buildRoutes( + makeFiles(["layout.tsx", "page.tsx", "blog/[slug]/page.tsx"]), + ); + expect(tree[0]!.filePath).toBe("layout.tsx"); + expect(tree[0]!.children!.map((child) => child.filePath)).toEqual([ + "page.tsx", + "blog/[slug]/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 f9e5cb5..9ebbdb5 100644 --- a/packages/static/src/fs-routes/nextAdapter.ts +++ b/packages/static/src/fs-routes/nextAdapter.ts @@ -25,7 +25,9 @@ interface TrieNode { /** Raw directory segment name (`""` for the routes-directory root). */ segment: string; page?: FsRouteModule; + pageFile?: string; layout?: FsRouteModule; + layoutFile?: string; children: Map; } @@ -178,20 +180,38 @@ function emit(node: TrieNode, prefix: string[]): FsRouteTreeNode[] { if (node.layout) { const children: FsRouteTreeNode[] = []; if (node.page) { - children.push({ path: "/", module: node.page, page: true }); + children.push({ + path: "/", + module: node.page, + filePath: node.pageFile, + page: true, + }); } 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 }]; + return [ + { + path, + module: node.layout, + filePath: node.layoutFile, + page: false, + children, + }, + ]; } const result: FsRouteTreeNode[] = []; if (node.page) { const path = here.length === 0 ? "/" : `/${here.join("/")}`; - result.push({ path, module: node.page, page: true }); + result.push({ + path, + module: node.page, + filePath: node.pageFile, + page: true, + }); } for (const child of childNodes) { result.push(...emit(child, here)); @@ -285,8 +305,10 @@ export function nextRoutes(options: NextRoutesOptions = {}): FsRoutesAdapter { const node = ensureDir(root, dirs); if (kind === "page") { node.page = file.module; + node.pageFile = file.filePath; } else { node.layout = file.module; + node.layoutFile = file.filePath; } } return emit(root, []); diff --git a/packages/static/src/fs-routes/tree.test.ts b/packages/static/src/fs-routes/tree.test.ts index c8079ed..cc19655 100644 --- a/packages/static/src/fs-routes/tree.test.ts +++ b/packages/static/src/fs-routes/tree.test.ts @@ -14,6 +14,24 @@ function pageModule( return { default: () => null, generateStaticParams }; } +function clientReference(name: string): () => never { + return Object.defineProperties( + (): never => { + throw new Error( + `Unexpectedly client reference export '${name}' is called on server`, + ); + }, + { $$typeof: { value: Symbol.for("react.client.reference") } }, + ); +} + +function clientPageModule(): FsRouteModule { + return { + default: clientReference("default"), + generateStaticParams: clientReference("generateStaticParams"), + }; +} + describe("collectStaticPaths", () => { it("collects static pages, including index pages under a layout", async () => { const tree: FsRouteTreeNode[] = [ @@ -113,6 +131,61 @@ describe("collectStaticPaths", () => { ]; await expect(collectStaticPaths(tree)).rejects.toThrow(/slug/); }); + + it("allows a client component page on a static route", async () => { + const tree: FsRouteTreeNode[] = [ + { + path: "/about", + page: true, + module: { default: clientReference("default") }, + filePath: "about/page.tsx", + }, + ]; + const pages = await collectStaticPaths(tree); + expect(pages).toEqual([{ urlPath: "/about", params: {} }]); + }); + + it('explains that a "use client" page cannot export generateStaticParams', async () => { + const tree: FsRouteTreeNode[] = [ + { + path: "/blog/:slug", + page: true, + module: clientPageModule(), + filePath: "blog/[slug]/page.tsx", + }, + ]; + await expect(collectStaticPaths(tree)).rejects.toThrow( + /\("blog\/\[slug\]\/page\.tsx"\).*marked "use client"/, + ); + }); + + it("names the source file in errors when the node carries one", async () => { + const tree: FsRouteTreeNode[] = [ + { + path: "/blog/:slug", + page: true, + module: component, + filePath: "blog/[slug]/page.tsx", + }, + ]; + await expect(collectStaticPaths(tree)).rejects.toThrow( + /\("blog\/\[slug\]\/page\.tsx"\) has no generateStaticParams/, + ); + }); + + it("names the source file when a param value is missing", async () => { + const tree: FsRouteTreeNode[] = [ + { + path: "/blog/:slug", + page: true, + module: pageModule(() => [{ other: "x" }]), + filePath: "blog/[slug]/page.tsx", + }, + ]; + await expect(collectStaticPaths(tree)).rejects.toThrow( + /\("blog\/\[slug\]\/page\.tsx"\) is missing a value for param "slug"/, + ); + }); }); describe("modulesToRouteFiles", () => { diff --git a/packages/static/src/fs-routes/tree.ts b/packages/static/src/fs-routes/tree.ts index 78b88a3..0108c4f 100644 --- a/packages/static/src/fs-routes/tree.ts +++ b/packages/static/src/fs-routes/tree.ts @@ -94,10 +94,33 @@ function isDynamicSegment(segment: string): boolean { return segment.startsWith(":"); } +const CLIENT_REFERENCE = Symbol.for("react.client.reference"); + +/** + * Whether a module export is a client reference, meaning the module is marked + * `"use client"`. React's `registerClientReference` tags every such export + * with `$$typeof`. + */ +function isClientReference(value: unknown): boolean { + return ( + typeof value === "function" && + "$$typeof" in value && + value.$$typeof === CLIENT_REFERENCE + ); +} + +/** + * Formats the source file of a route for an error message, when known. + */ +function inFile(filePath: string | undefined): string { + return filePath === undefined ? "" : ` ("${filePath}")`; +} + async function addPagesForLeaf( segments: string[], module: FsRouteModule, pages: StaticPage[], + filePath: string | undefined, ): Promise { const dynamicSegments = segments.filter(isDynamicSegment); @@ -107,9 +130,19 @@ async function addPagesForLeaf( } const generate = module.generateStaticParams; + if (isClientReference(generate)) { + throw new Error( + `Dynamic route "${segmentsToUrl(segments)}"${inFile(filePath)} exports ` + + `generateStaticParams() from a module marked "use client". ` + + `generateStaticParams() runs on the server at build time, so a page module ` + + `cannot be a Client Component. Move the component body into a separate ` + + `"use client" module and re-export it from the page: ` + + `export { default } from "./_page";`, + ); + } if (typeof generate !== "function") { throw new Error( - `Dynamic route "${segmentsToUrl(segments)}" has no generateStaticParams() export. ` + + `Dynamic route "${segmentsToUrl(segments)}"${inFile(filePath)} has no generateStaticParams() export. ` + `Every page of a static site must be enumerated at build time; ` + `export generateStaticParams() from the page module to list the params to pre-render.`, ); @@ -123,7 +156,7 @@ async function addPagesForLeaf( const value = params[name]; if (value === undefined) { throw new Error( - `generateStaticParams() for "${segmentsToUrl(segments)}" is missing a value for param "${name}".`, + `generateStaticParams() for "${segmentsToUrl(segments)}"${inFile(filePath)} is missing a value for param "${name}".`, ); } return value; @@ -142,7 +175,7 @@ async function walk( node.path !== undefined ? splitRoutePath(node.path) : []; const segments = [...prefixSegments, ...ownSegments]; if (node.page) { - await addPagesForLeaf(segments, node.module, pages); + await addPagesForLeaf(segments, node.module, pages, node.filePath); } if (node.children) { await walk(node.children, segments, pages); diff --git a/packages/static/src/fs-routes/types.ts b/packages/static/src/fs-routes/types.ts index 0b2ef6d..9872ac2 100644 --- a/packages/static/src/fs-routes/types.ts +++ b/packages/static/src/fs-routes/types.ts @@ -17,6 +17,10 @@ export interface FsRouteModule { * whose route contains a dynamic segment; the build fails without it, since * a static site cannot serve pages that were not enumerated at build time. * + * Runs on the server at build time, so the exporting module cannot be + * marked `"use client"`; move the page body into a separate `"use client"` + * module and re-export it as `default` instead. + * * Returns the list of concrete params to pre-render. Each entry maps every * dynamic param name in the route's path to a concrete string value. For a * catch-all segment, the value may contain slashes. @@ -58,6 +62,12 @@ export interface FsRouteTreeNode { path?: string; /** The module providing this node's component (page or layout). */ module: FsRouteModule; + /** + * Path of the file that provided this node's module, relative to the routes + * directory (as in {@link FsRouteFile.filePath}). Adapters should set this + * so that error messages can name the offending file. + */ + filePath?: string; /** * Whether this node is a concrete page that should be statically generated. * Layout nodes set this to `false`.