From c0f0c6ef269ea923ddb62785cb81655cb80dd371 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 05:32:26 +0000 Subject: [PATCH 1/2] fix: cache fs-routes enumeration across dev requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every dev request iterated getEntries(), re-running the whole site enumeration — including every generateStaticParams() — and registering a fresh set of chunks under new random IDs. On large sites this made every request pay the full-site cost, and the flood of registrations pushed older chunk IDs out of the dev defer registry, so long-idle tabs lost their soft-navigation chunks and fell back to hard navigation. The enumeration (route tree, generateStaticParams(), chunk registration) now runs once per entries-module instance and is cached; editing a routed file invalidates the module in dev and re-enumerates, and a build iterates getEntries() only once so the cache is inert there. Chunk IDs stay stable across requests, and any chunk the dev registry evicts is re-registered under its original ID on the next request, keeping payloads held by open tabs soft-navigable. A failed enumeration is not cached, so a transient generateStaticParams() error does not stick for the session. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016D8ULaGdQjQ493LBy3ow1S --- .../src/pages/learn/FileSystemRouting.mdx | 2 + packages/static/src/fs-routes/entries.tsx | 6 +- packages/static/src/fs-routes/runtime.test.ts | 126 +++++++++++++++++- packages/static/src/fs-routes/runtime.tsx | 94 ++++++++++++- 4 files changed, 218 insertions(+), 10 deletions(-) diff --git a/packages/docs/src/pages/learn/FileSystemRouting.mdx b/packages/docs/src/pages/learn/FileSystemRouting.mdx index fdfe577..9e8010a 100644 --- a/packages/docs/src/pages/learn/FileSystemRouting.mdx +++ b/packages/docs/src/pages/learn/FileSystemRouting.mdx @@ -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 diff --git a/packages/static/src/fs-routes/entries.tsx b/packages/static/src/fs-routes/entries.tsx index 01876bf..fe3751a 100644 --- a/packages/static/src/fs-routes/entries.tsx +++ b/packages/static/src/fs-routes/entries.tsx @@ -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, @@ -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, }; diff --git a/packages/static/src/fs-routes/runtime.test.ts b/packages/static/src/fs-routes/runtime.test.ts index cd21179..ef3f476 100644 --- a/packages/static/src/fs-routes/runtime.test.ts +++ b/packages/static/src/fs-routes/runtime.test.ts @@ -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, @@ -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>; } { const registered: RegisteredChunk[] = []; + const restored: RegisteredChunk[] = []; + const live = new Map>(); const host: FsRoutesRuntimeHost = { registerChunk(element, name) { const id = `fun__rsc-payload/chunk-${registered.length}`; @@ -40,11 +45,21 @@ function fakeHost(): { name, id, }); + live.set(id, element as ReactElement); return id; }, + hasChunk: (id) => live.has(id), + restoreChunk(element, id, name) { + restored.push({ + element: element as ReactElement, + name, + id, + }); + live.set(id, element as ReactElement); + }, RouteSlot: FakeSlot, }; - return { host, registered }; + return { host, registered, restored, live }; } interface DefinitionLike { @@ -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, + host: FsRoutesRuntimeHost, + ) { + return createFsRoutesEntriesWithHost( + { modules, base: "./pages", root: Root }, + host, + ); + } + + async function drain( + getEntries: () => + AsyncIterable | Iterable, + ): Promise { + 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); + }); +}); diff --git a/packages/static/src/fs-routes/runtime.tsx b/packages/static/src/fs-routes/runtime.tsx index d9c0987..8c2f63b 100644 --- a/packages/static/src/fs-routes/runtime.tsx +++ b/packages/static/src/fs-routes/runtime.tsx @@ -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 @@ -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, host: FsRoutesRuntimeHost, -): void { +): Map { const combos = new Map< FsRouteTreeNode, Map> @@ -188,17 +214,30 @@ function registerChunks( } } } + const registered = new Map(); 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; + pages: StaticPage[]; + chunks: Map; } /** @@ -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. @@ -302,7 +346,7 @@ export function createFsRoutesEntriesWithHost( }); } - return async function* getEntries(): AsyncGenerator { + async function enumerateRoutes(): Promise { const warn = (message: string) => { console.warn(`[funstack] ${message}`); }; @@ -312,7 +356,43 @@ export function createFsRoutesEntriesWithHost( const pages = await collectStaticPaths(tree); const metas = new Map(); 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 | undefined; + + return async function* getEntries(): AsyncGenerator { + 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), From ab49689d272493c3a97e3f7f90a3bc49139664cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 10:16:59 +0000 Subject: [PATCH 2/2] fix: restore an evicted fs-route chunk when its fetch misses the registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The restore pass runs only when getEntries() is iterated (HTML requests and HMR re-fetches), but a chunk fetch on soft navigation goes through serveRSC's module path, which never iterates the entries. With chunk IDs now stable across requests, a settled chunk idle past the eviction TTL could be dropped — by evictStale at the start of the very request fetching it — and 404, falling back to hard navigation. On a registry miss, iterate the entries once (cheap: the enumeration is cached) to re-register the current chunk set under its original IDs, then retry the lookup before returning 404. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016D8ULaGdQjQ493LBy3ow1S --- packages/static/src/rsc/entry.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/static/src/rsc/entry.tsx b/packages/static/src/rsc/entry.tsx index 113fe41..7248367 100644 --- a/packages/static/src/rsc/entry.tsx +++ b/packages/static/src/rsc/entry.tsx @@ -236,7 +236,17 @@ export async function serveRSC(request: Request): Promise { } 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); }