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 @@ -128,6 +128,8 @@ Param values must be non-empty strings that stay within their URL segment: a reg

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.

During development, the site's route enumeration — including every `generateStaticParams` call — runs once and is cached, rather than on every request. Editing any file under the routes directory refreshes the cache automatically. If `generateStaticParams` derives its result from external data (say, a CMS), pages added to that data appear after you edit a routed file or restart the dev server.

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
6 changes: 5 additions & 1 deletion packages/static/src/fs-routes/entries.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FsRouteSlot } from "#rsc-client";
import { registerDeferredPayload } from "../rsc/defer";
import { deferRegistry, registerDeferredPayload } from "../rsc/defer";
import type { GetEntriesResult } from "../entryDefinition";
import {
createFsRoutesEntriesWithHost,
Expand All @@ -15,6 +15,10 @@ import {
*/
const rscRuntimeHost: FsRoutesRuntimeHost = {
registerChunk: registerDeferredPayload,
hasChunk: (id) => deferRegistry.has(id),
restoreChunk: (element, id, name) => {
deferRegistry.register(element, id, name);
},
RouteSlot: FsRouteSlot,
};

Expand Down
126 changes: 124 additions & 2 deletions packages/static/src/fs-routes/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { isValidElement, type ReactElement } from "react";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
createFsRoutesEntriesWithHost,
type FsRoutesRuntimeHost,
Expand Down Expand Up @@ -30,8 +30,13 @@ interface RegisteredChunk {
function fakeHost(): {
host: FsRoutesRuntimeHost;
registered: RegisteredChunk[];
restored: RegisteredChunk[];
/** The chunks currently live in the (fake) defer registry, by ID. */
live: Map<string, ReactElement<FsRouteComponentProps>>;
} {
const registered: RegisteredChunk[] = [];
const restored: RegisteredChunk[] = [];
const live = new Map<string, ReactElement<FsRouteComponentProps>>();
const host: FsRoutesRuntimeHost = {
registerChunk(element, name) {
const id = `fun__rsc-payload/chunk-${registered.length}`;
Expand All @@ -40,11 +45,21 @@ function fakeHost(): {
name,
id,
});
live.set(id, element as ReactElement<FsRouteComponentProps>);
return id;
},
hasChunk: (id) => live.has(id),
restoreChunk(element, id, name) {
restored.push({
element: element as ReactElement<FsRouteComponentProps>,
name,
id,
});
live.set(id, element as ReactElement<FsRouteComponentProps>);
},
RouteSlot: FakeSlot,
};
return { host, registered };
return { host, registered, restored, live };
}

interface DefinitionLike {
Expand Down Expand Up @@ -240,3 +255,110 @@ describe("createFsRoutesEntries route definitions", () => {
).rejects.toThrow(/layout module "layout\.tsx" has no default export/);
});
});

describe("createFsRoutesEntries enumeration caching", () => {
function factoryFor(
modules: Record<string, FsRouteModule>,
host: FsRoutesRuntimeHost,
) {
return createFsRoutesEntriesWithHost(
{ modules, base: "./pages", root: Root },
host,
);
}

async function drain(
getEntries: () =>
AsyncIterable<EntryDefinition> | Iterable<EntryDefinition>,
): Promise<EntryDefinition[]> {
const entries: EntryDefinition[] = [];
for await (const entry of getEntries()) {
entries.push(entry);
}
return entries;
}

it("enumerates the site once and reuses chunk IDs across iterations", async () => {
const generateStaticParams = vi.fn(() => [{ lang: "en" }, { lang: "ja" }]);
const { host, registered } = fakeHost();
const getEntries = factoryFor(
{
"./pages/[lang]/page.tsx": {
default: () => null,
generateStaticParams,
},
"./pages/about/page.tsx": { default: () => null },
},
host,
);

const first = await drain(getEntries);
const second = await drain(getEntries);

// The dev server iterates getEntries() per request; generateStaticParams
// and chunk registration must not run again.
expect(generateStaticParams).toHaveBeenCalledTimes(1);
expect(registered).toHaveLength(3);

// The second iteration serves the same chunk IDs, so payloads held by
// earlier requests stay consistent with the registry.
const chunksOf = (entries: EntryDefinition[], path: string) => {
const routes = routesOfEntry(entries.find((e) => e.path === path)!);
const definitions = collectDefinitions(routes);
const page = definitions.find((d) => d.path === "/:lang")!;
return slotOf(page).chunks;
};
expect(chunksOf(second, "en.html")).toEqual(chunksOf(first, "en.html"));
});

it("re-registers evicted chunks under their original IDs", async () => {
const { host, registered, restored, live } = fakeHost();
const getEntries = factoryFor(
{
"./pages/[lang]/page.tsx": {
default: () => null,
generateStaticParams: () => [{ lang: "en" }, { lang: "ja" }],
},
},
host,
);
await drain(getEntries);
expect(registered).toHaveLength(2);

// Simulate the dev defer registry evicting one chunk between requests.
const evicted = registered[0]!;
live.delete(evicted.id);

await drain(getEntries);
expect(restored).toHaveLength(1);
expect(restored[0]!.id).toBe(evicted.id);
expect(restored[0]!.name).toBe(evicted.name);
expect(restored[0]!.element).toBe(evicted.element);
expect(live.has(evicted.id)).toBe(true);
});

it("does not cache a failed enumeration", async () => {
const generateStaticParams = vi
.fn<() => { lang: string }[]>()
.mockImplementationOnce(() => {
throw new Error("CMS is down");
})
.mockImplementation(() => [{ lang: "en" }]);
const { host } = fakeHost();
const getEntries = factoryFor(
{
"./pages/[lang]/page.tsx": {
default: () => null,
generateStaticParams,
},
},
host,
);

await expect(drain(getEntries)).rejects.toThrow("CMS is down");
// The next request retries instead of replaying the cached failure.
const entries = await drain(getEntries);
expect(entries.map((e) => e.path)).toEqual(["en.html"]);
expect(generateStaticParams).toHaveBeenCalledTimes(2);
});
});
94 changes: 87 additions & 7 deletions packages/static/src/fs-routes/runtime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ export interface FsRoutesRuntimeHost {
* `name` is a debugging label for build logs.
*/
registerChunk(element: ReactElement, name: string): string;
/**
* Whether the chunk registered under `id` is still available. In dev the
* defer registry evicts entries over time, so a chunk registered by an
* earlier request may be gone by the next one.
*/
hasChunk(id: string): boolean;
/**
* Re-registers an evicted chunk under its original payload ID, so that
* payloads already served to clients (whose `chunks` maps bake in that ID)
* can still fetch it on soft navigation.
*/
restoreChunk(element: ReactElement, id: string, name: string): void;
/**
* The client component standing in for Server Component route nodes,
* resolving the chunk for the current match's params. A client reference
Expand Down Expand Up @@ -155,17 +167,31 @@ function buildNodeMetas(
});
}

/**
* A registered chunk kept for re-registration: the dev defer registry may
* evict the chunk while its payload ID is still baked into payloads held by
* open tabs, and re-registering the same element under the same ID lets
* those tabs keep soft-navigating.
*/
interface RegisteredChunk {
element: ReactElement;
name: string;
}

/**
* Registers one pre-rendered RSC chunk per Server Component node per params
* combination occurring among the generated pages, filling each node's
* `chunks` map. Client-side soft navigation fetches these chunks to render
* Server Component output for the destination's params.
*
* Returns the registered chunks by payload ID, for later restoration of
* entries the dev registry has evicted.
*/
function registerChunks(
pages: StaticPage[],
metas: Map<FsRouteTreeNode, NodeMeta>,
host: FsRoutesRuntimeHost,
): void {
): Map<string, RegisteredChunk> {
const combos = new Map<
FsRouteTreeNode,
Map<string, Record<string, string>>
Expand All @@ -188,17 +214,30 @@ function registerChunks(
}
}
}
const registered = new Map<string, RegisteredChunk>();
for (const [node, nodeCombos] of combos) {
const meta = metas.get(node)!;
const Component = node.module.default!;
for (const [key, params] of nodeCombos) {
const element = createElement(Component, { params, route: meta.route });
meta.chunks[key] = host.registerChunk(
element,
`fs-route ${node.filePath ?? meta.id} ${key}`,
);
const name = `fs-route ${node.filePath ?? meta.id} ${key}`;
const id = host.registerChunk(element, name);
meta.chunks[key] = id;
registered.set(id, { element, name });
}
}
return registered;
}

/**
* The result of enumerating the whole site once: the route tree, per-node
* metadata, every statically-generated page, and the registered chunks.
*/
interface EnumeratedRoutes {
tree: FsRouteTreeNode[];
metas: Map<FsRouteTreeNode, NodeMeta>;
pages: StaticPage[];
chunks: Map<string, RegisteredChunk>;
}

/**
Expand All @@ -210,6 +249,11 @@ function registerChunks(
* are rebuilt per page so that the page's own Server Component output can be
* inlined into its payload.
*
* The enumeration itself (route tree, `generateStaticParams()` of every
* dynamic route, chunk registration) runs once and is cached for the
* lifetime of the module instance, however many times `getEntries()` is
* iterated — see the comment on `enumerated` below.
*
* This is the host-parameterized implementation behind
* `createFsRoutesEntries` (see `./entries`), kept free of RSC-runtime
* imports so it stays testable outside a Vite environment.
Expand Down Expand Up @@ -302,7 +346,7 @@ export function createFsRoutesEntriesWithHost(
});
}

return async function* getEntries(): AsyncGenerator<EntryDefinition> {
async function enumerateRoutes(): Promise<EnumeratedRoutes> {
const warn = (message: string) => {
console.warn(`[funstack] ${message}`);
};
Expand All @@ -312,7 +356,43 @@ export function createFsRoutesEntriesWithHost(
const pages = await collectStaticPaths(tree);
const metas = new Map<FsRouteTreeNode, NodeMeta>();
buildNodeMetas(tree, [], "", metas);
registerChunks(pages, metas, host);
const chunks = registerChunks(pages, metas, host);
return { tree, metas, pages, chunks };
}

// The enumeration is cached for the lifetime of this module instance. The
// dev server iterates getEntries() on every request; without the cache,
// each request would re-run every generateStaticParams() and register a
// fresh set of chunks under new IDs, flooding the dev defer registry
// until chunk IDs held by other (or long-idle) tabs are evicted and their
// soft navigation falls back to hard navigation. Editing a routed
// file invalidates the entries module in dev, so a fresh module instance
// re-enumerates; a build iterates getEntries() once, making the cache
// inert there.
let enumerated: Promise<EnumeratedRoutes> | undefined;

return async function* getEntries(): AsyncGenerator<EntryDefinition> {
if (enumerated === undefined) {
const attempt = enumerateRoutes();
// A failed enumeration (e.g. a transient error thrown by a
// generateStaticParams() fetching data) is not cached, so the next
// request retries instead of failing for the rest of the session.
attempt.catch(() => {
if (enumerated === attempt) {
enumerated = undefined;
}
});
enumerated = attempt;
}
const { tree, metas, pages, chunks } = await enumerated;
// Re-register any chunk the dev registry evicted since enumeration,
// under its original ID: payloads already served bake chunk IDs into
// their slots, and restoring the ID keeps those pages soft-navigable.
for (const [id, { element, name }] of chunks) {
if (!host.hasChunk(id)) {
host.restoreChunk(element, id, name);
}
}
for (const page of pages) {
yield {
path: urlPathToFilePath(page.urlPath),
Expand Down
12 changes: 11 additions & 1 deletion packages/static/src/rsc/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,17 @@ export async function serveRSC(request: Request): Promise<Response> {
}

const deferLoadStart = performance.now();
const entry = deferRegistry.load(moduleId);
let entry = deferRegistry.load(moduleId);
if (!entry) {
// The entry may be an fs-route chunk that evictStale dropped (settled
// entries expire after ttlMs) while its ID is still baked into a page
// held by an open tab. Iterating the entries re-registers the current
// chunk set under its original IDs (cheap: the fs-routes enumeration is
// cached), so retry once after. An ID from before a dev-server restart
// or a routed-file edit stays missing and 404s as before.
await loadEntriesList();
entry = deferRegistry.load(moduleId);
}
if (!entry) {
throw new ServeRSCError(`RSC component not found: ${moduleId}`, 404);
}
Expand Down