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
2 changes: 2 additions & 0 deletions packages/docs/src/pages/learn/FileSystemRouting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 2 additions & 8 deletions packages/static/src/fs-routes/entries.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
};

Expand Down
13 changes: 9 additions & 4 deletions packages/static/src/fs-routes/runtime.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -227,7 +232,7 @@ export function createFsRoutesEntriesWithHost(
const definition: {
id: string;
path?: string;
component?: React.ComponentType<object> | React.ReactNode;
component?: ComponentType<object> | ReactNode;
children?: RouteDefinition[];
} = { id: meta.id };
if (node.path !== undefined) {
Expand All @@ -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<object>;
definition.component = Component as ComponentType<object>;
} else {
// A Server Component crosses the RSC boundary only as its rendered
// output, so a client slot stands in for it: it renders the
Expand Down Expand Up @@ -283,7 +288,7 @@ export function createFsRoutesEntriesWithHost(
tree: FsRouteTreeNode[];
metas: Map<FsRouteTreeNode, NodeMeta>;
page: StaticPage;
}): React.ReactNode {
}): ReactNode {
const routes = buildRouteDefinitions(
tree,
metas,
Expand Down
49 changes: 49 additions & 0 deletions packages/static/src/fs-routes/tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
{
Expand Down
40 changes: 39 additions & 1 deletion packages/static/src/fs-routes/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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<StaticPage[]> {
const pages: StaticPage[] = [];
await walk(tree, [], [], pages);
return pages;
const byUrl = new Map<string, StaticPage>();
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;
}

/**
Expand Down
4 changes: 4 additions & 0 deletions packages/static/src/fs-routes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<Record<string, string>>>;
[key: string]: unknown;
Expand Down
28 changes: 20 additions & 8 deletions packages/static/src/rsc/defer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,25 @@ export const deferRegistry = new DeferRegistry((element) =>
renderToReadableStream<ReactNode>(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.
*
Expand All @@ -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 <DeferredComponent moduleID={id} />;
}