From e85b2d44656d828effd4bedf3f6a66cc0a7d68fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 02:01:23 +0000 Subject: [PATCH] feat: fail the build for dynamic fs-routes without generateStaticParams Previously a dynamic route without generateStaticParams() was skipped with a warning claiming it "still works on the client via the SPA fallback". That claim was only half-true: RSC payloads are serialized at build time with each entry's concrete params, so a server-component page reached via the SPA fallback renders with empty params, and the fallback itself requires host-level rewrite configuration. Since a static site can only serve pages enumerated at build time, the missing export is now a build error instead of a silent skip. The unused onWarn parameter is removed from collectStaticPaths, and the docs no longer advertise the SPA fallback for dynamic routes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0183g2LXhE2PfPpg5KTmFLrM --- .../src/pages/learn/FileSystemRouting.mdx | 4 ++-- packages/static/src/fs-routes/runtime.tsx | 2 +- packages/static/src/fs-routes/tree.test.ts | 10 ++++----- packages/static/src/fs-routes/tree.ts | 22 +++++++++---------- packages/static/src/fs-routes/types.ts | 7 +++--- 5 files changed, 20 insertions(+), 25 deletions(-) diff --git a/packages/docs/src/pages/learn/FileSystemRouting.mdx b/packages/docs/src/pages/learn/FileSystemRouting.mdx index 4e1a8eb..dddf86f 100644 --- a/packages/docs/src/pages/learn/FileSystemRouting.mdx +++ b/packages/docs/src/pages/learn/FileSystemRouting.mdx @@ -120,9 +120,9 @@ export default function BlogPost({ params }: { params: { slug: string } }) { This generates `blog/hello.html` and `blog/world.html`. Each page component receives the resolved `params` as a prop. -A dynamic route without `generateStaticParams` is **not** pre-rendered (a warning is logged); it still resolves on the client via the SPA fallback. +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. -> **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 (or via the SPA fallback) always renders the correct params. Static routes and layouts navigate fully on the 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/runtime.tsx b/packages/static/src/fs-routes/runtime.tsx index a3ab55c..cf8319f 100644 --- a/packages/static/src/fs-routes/runtime.tsx +++ b/packages/static/src/fs-routes/runtime.tsx @@ -115,7 +115,7 @@ export function createFsRoutesEntries( }; const files = modulesToRouteFiles(modules, warn); const tree = adapter.buildRoutes(files); - const pages = await collectStaticPaths(tree, warn); + const pages = await collectStaticPaths(tree); for (const { urlPath, params } of pages) { yield { path: urlPathToFilePath(urlPath), diff --git a/packages/static/src/fs-routes/tree.test.ts b/packages/static/src/fs-routes/tree.test.ts index 3060c33..30a847d 100644 --- a/packages/static/src/fs-routes/tree.test.ts +++ b/packages/static/src/fs-routes/tree.test.ts @@ -94,15 +94,13 @@ describe("collectStaticPaths", () => { ]); }); - it("warns and skips a dynamic route without generateStaticParams", async () => { + it("throws for a dynamic route without generateStaticParams", async () => { const tree: FsRouteTreeNode[] = [ { path: "/blog/:slug", page: true, module: component }, ]; - const warn = vi.fn(); - const pages = await collectStaticPaths(tree, warn); - expect(pages).toEqual([]); - expect(warn).toHaveBeenCalledTimes(1); - expect(warn.mock.calls[0]?.[0]).toContain("/blog/:slug"); + await expect(collectStaticPaths(tree)).rejects.toThrow( + /"\/blog\/:slug".*generateStaticParams/, + ); }); it("throws when generateStaticParams is missing a param value", async () => { diff --git a/packages/static/src/fs-routes/tree.ts b/packages/static/src/fs-routes/tree.ts index 95dff7c..20a17d8 100644 --- a/packages/static/src/fs-routes/tree.ts +++ b/packages/static/src/fs-routes/tree.ts @@ -87,7 +87,6 @@ async function addPagesForLeaf( segments: string[], module: FsRouteModule, pages: StaticPage[], - onWarn?: (message: string) => void, ): Promise { const dynamicSegments = segments.filter(isDynamicSegment); @@ -98,11 +97,11 @@ async function addPagesForLeaf( const generate = module.generateStaticParams; if (typeof generate !== "function") { - onWarn?.( - `Dynamic route "${segmentsToUrl(segments)}" has no generateStaticParams() export; skipping static generation. ` + - `It will still work on the client via the SPA fallback.`, + throw new Error( + `Dynamic route "${segmentsToUrl(segments)}" 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.`, ); - return; } const paramSets = await generate(); @@ -126,17 +125,16 @@ async function walk( nodes: FsRouteTreeNode[], prefixSegments: string[], pages: StaticPage[], - onWarn?: (message: string) => void, ): Promise { for (const node of nodes) { const ownSegments = node.path !== undefined ? splitRoutePath(node.path) : []; const segments = [...prefixSegments, ...ownSegments]; if (node.page) { - await addPagesForLeaf(segments, node.module, pages, onWarn); + await addPagesForLeaf(segments, node.module, pages); } if (node.children) { - await walk(node.children, segments, pages, onWarn); + await walk(node.children, segments, pages); } } } @@ -146,15 +144,15 @@ async function walk( * * Static routes are emitted directly. Dynamic routes (with `:param` or * catch-all segments) are expanded using each page module's - * `generateStaticParams()`; if absent, the route is skipped and `onWarn` is - * invoked. + * `generateStaticParams()`; a dynamic route without that export fails the + * build, since a static site cannot serve pages that were not enumerated at + * build time. */ export async function collectStaticPaths( tree: FsRouteTreeNode[], - onWarn?: (message: string) => void, ): Promise { const pages: StaticPage[] = []; - await walk(tree, [], pages, onWarn); + await walk(tree, [], pages); return pages; } diff --git a/packages/static/src/fs-routes/types.ts b/packages/static/src/fs-routes/types.ts index 6f5278a..0b2ef6d 100644 --- a/packages/static/src/fs-routes/types.ts +++ b/packages/static/src/fs-routes/types.ts @@ -13,14 +13,13 @@ export interface FsRouteModule { /** The component for this page or layout. */ default?: ComponentType<{ params: Record }> | ComponentType; /** - * Optional function used to statically generate a dynamic route. + * Function used to statically generate a dynamic route. Required for pages + * 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. * * 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. - * - * Without this export, a dynamic route is not pre-rendered to HTML (it still - * works on the client via the SPA fallback). */ generateStaticParams?: () => MaybePromise>>; [key: string]: unknown;