Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/docs/src/pages/learn/FileSystemRouting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions packages/static/src/fs-routes/nextAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 25 additions & 3 deletions packages/static/src/fs-routes/nextAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, TrieNode>;
}

Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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, []);
Expand Down
73 changes: 73 additions & 0 deletions packages/static/src/fs-routes/tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down Expand Up @@ -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", () => {
Expand Down
39 changes: 36 additions & 3 deletions packages/static/src/fs-routes/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const dynamicSegments = segments.filter(isDynamicSegment);

Expand All @@ -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.`,
);
Expand All @@ -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;
Expand All @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions packages/static/src/fs-routes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`.
Expand Down