From 34c4a97b86e3d6c64cf9e05ea943cc9b9a97dbec Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:07:09 +0000 Subject: [PATCH] feat: render Server Component output with live params on soft navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under file-system routing, a Server Component page or layout kept its build-time params (and output) after soft client-side navigation between pages of the same dynamic route, with the destination's pre-rendered payload unreachable in the build output (#174, remaining half). Server Component route nodes are now represented by a client slot (FsRouteSlot) in the route definitions instead of a baked element. The build pre-renders one RSC chunk per node per params combination through the existing defer registry (content-hashed, immutably cacheable), and bakes the params-to-chunk map into the slot. The slot reads the live params of the current match via the route object and renders the inline build-time output while they match the page's own params, or suspends on the destination's chunk after a soft navigation — inside the router's transition, so the old page stays visible until the new output is ready. A failed chunk resolution (a params combination that was never generated, or a fetch failure after a redeploy) falls back to a full page load, with a sessionStorage guard against reload loops. Since chunks are shared per params combination, this also stops each page's main payload from embedding every other page's rendered output, and Server Component layouts now receive only the params of segments at or above them (matching their per-combination pre-rendering) rather than the full page params. Closes #174 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01ErSPPfkksH1SqMULJdivfG --- .../src/pages/learn/FileSystemRouting.mdx | 10 +- .../src/pages/[lang]/info/layout.tsx | 15 ++ .../src/pages/[lang]/info/page.tsx | 27 +++ .../src/pages/[lang]/page.tsx | 3 + .../static/e2e/tests-dev/fs-routing.spec.ts | 21 ++ packages/static/e2e/tests/fs-routing.spec.ts | 43 +++- packages/static/src/fs-routes/entries.tsx | 50 ++++ packages/static/src/fs-routes/index.ts | 3 +- packages/static/src/fs-routes/runtime.test.ts | 140 ++++++++++- packages/static/src/fs-routes/runtime.tsx | 225 ++++++++++++++---- packages/static/src/fs-routes/slot.ts | 69 ++++++ packages/static/src/fs-routes/tree.test.ts | 33 ++- packages/static/src/fs-routes/tree.ts | 25 +- packages/static/src/rsc-client/entry.ts | 1 + .../static/src/rsc-client/fsRouteSlot.tsx | 107 +++++++++ 15 files changed, 692 insertions(+), 80 deletions(-) create mode 100644 packages/static/e2e/fixture-fs-routing/src/pages/[lang]/info/layout.tsx create mode 100644 packages/static/e2e/fixture-fs-routing/src/pages/[lang]/info/page.tsx create mode 100644 packages/static/src/fs-routes/entries.tsx create mode 100644 packages/static/src/fs-routes/slot.ts create mode 100644 packages/static/src/rsc-client/fsRouteSlot.tsx diff --git a/packages/docs/src/pages/learn/FileSystemRouting.mdx b/packages/docs/src/pages/learn/FileSystemRouting.mdx index 1205af8..e632714 100644 --- a/packages/docs/src/pages/learn/FileSystemRouting.mdx +++ b/packages/docs/src/pages/learn/FileSystemRouting.mdx @@ -134,10 +134,14 @@ export { default } from "./_page"; // _page.tsx is marked "use client" ## Params on Client-Side Navigation -Loading any generated URL directly always renders the correct params. During soft client-side navigation between pages of the _same_ dynamic route (say, from `/blog/hello` to `/blog/world`), what the `params` prop holds depends on the kind of the component: +Loading any generated URL directly always renders the correct params, and so does soft client-side navigation between pages of the _same_ dynamic route (say, from `/blog/hello` to `/blog/world`) — through two different mechanisms depending on the kind of the component: -- **Client Components** (pages and layouts marked `"use client"`) are rendered by FUNSTACK Router in the browser, so they receive the **live** params of the URL currently shown. They stay correct across soft navigation. -- **Server Components** render once at build time, so their `params` prop — and their entire rendered output — currently reflects the values the page was generated with after such a navigation. This is a temporary limitation, not the intended end state: the destination page's pre-rendered RSC payload exists in the build output, and loading it on client-side navigation so that Server Component pages update too is being worked on ([#174](https://github.com/uhyo/funstack-static/issues/174)). Until then, use the route object below to read live params, or make the page body a Client Component if it must fully react to param changes. +- **Client Components** (pages and layouts marked `"use client"`) are rendered by FUNSTACK Router in the browser, so they receive the **live** params of the URL currently shown. +- **Server Components** are pre-rendered at build time, once per params combination enumerated by `generateStaticParams`. On soft navigation, the destination's pre-rendered output is fetched as a static RSC chunk and swapped in, so the `params` prop — and the entire rendered output — matches the URL shown. The output for the params a chunk covers is shared: navigating between pages under the same Server Component layout re-uses the layout's chunk instead of fetching it again. + +Because a Server Component's output is pre-rendered per params combination, a Server Component **layout** receives only the params of the dynamic segments at or above it — a layout at `[lang]/` sees `{ lang }`, not the `{ slug }` of a page below it. (Client Component layouts receive the router's live params of the current match.) + +Soft navigation can only render params combinations that were statically generated. If a navigation targets a combination that was never generated — or a chunk fetch fails, for example because a new deployment replaced the build output — the router falls back to a full page load of the destination URL. ### Reading Live Params with the Route Object diff --git a/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/info/layout.tsx b/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/info/layout.tsx new file mode 100644 index 0000000..cdd2c7c --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/info/layout.tsx @@ -0,0 +1,15 @@ +import { Outlet } from "@funstack/router"; +import type { FsRouteComponentProps } from "@funstack/static/fs-routes"; + +// A Server Component layout under a dynamic segment: its output is +// pre-rendered per lang and swapped in on soft client-side navigation. +export default function InfoLayout({ + params, +}: FsRouteComponentProps<{ lang: string }>) { + return ( +
+

{params.lang}

+ +
+ ); +} diff --git a/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/info/page.tsx b/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/info/page.tsx new file mode 100644 index 0000000..d9c2fde --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/info/page.tsx @@ -0,0 +1,27 @@ +import type { FsRouteComponentProps } from "@funstack/static/fs-routes"; + +export function generateStaticParams() { + return [{ lang: "en" }, { lang: "ja" }]; +} + +// A Server Component page under a Server Component layout, both below a +// dynamic segment. +export default function InfoPage({ + params, +}: FsRouteComponentProps<{ lang: string }>) { + return ( +
+

info

+

{params.lang}

+ + English info + {" "} + + Japanese info + {" "} + + English home + +
+ ); +} diff --git a/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/page.tsx b/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/page.tsx index 6ab1a4b..ece8f8c 100644 --- a/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/page.tsx +++ b/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/page.tsx @@ -24,6 +24,9 @@ export default function LangPage({ {" "} English client page + {" "} + + English info ); diff --git a/packages/static/e2e/tests-dev/fs-routing.spec.ts b/packages/static/e2e/tests-dev/fs-routing.spec.ts index 4c3a65e..0ca3eb7 100644 --- a/packages/static/e2e/tests-dev/fs-routing.spec.ts +++ b/packages/static/e2e/tests-dev/fs-routing.spec.ts @@ -56,4 +56,25 @@ test.describe("File-system routing (dev server)", () => { await page.getByTestId("link-en").click(); await expect(page.getByTestId("live-lang")).toHaveText("en"); }); + + test("a Server Component page re-renders with the destination's params", async ({ + page, + }) => { + await page.goto("/ja"); + await expect(page.getByTestId("lang-page-lang")).toHaveText("ja"); + + await page.getByTestId("link-en").click(); + await expect(page.getByTestId("lang-page-lang")).toHaveText("en"); + }); + + test("a Server Component layout under a dynamic segment updates on soft navigation", async ({ + page, + }) => { + await page.goto("/en/info"); + await expect(page.getByTestId("info-layout-lang")).toHaveText("en"); + + await page.getByTestId("link-ja-info").click(); + await expect(page.getByTestId("info-layout-lang")).toHaveText("ja"); + await expect(page.getByTestId("info-page-lang")).toHaveText("ja"); + }); }); diff --git a/packages/static/e2e/tests/fs-routing.spec.ts b/packages/static/e2e/tests/fs-routing.spec.ts index d0f1165..7ce9e4f 100644 --- a/packages/static/e2e/tests/fs-routing.spec.ts +++ b/packages/static/e2e/tests/fs-routing.spec.ts @@ -15,6 +15,8 @@ test.describe("File-system routing build output", () => { "/ja", "/en/client", "/ja/client", + "/en/info", + "/ja/info", ]) { const response = await request.get(path); expect(response.ok(), `expected ${path} to be served`).toBe(true); @@ -142,9 +144,46 @@ test.describe("Dynamic params on soft client-side navigation", () => { await page.getByTestId("link-en").click(); await expect(page.getByTestId("live-lang")).toHaveText("en"); - // The Server Component page's own output was rendered at build time and - // keeps its build-time params — the documented static-hosting limitation. + }); + + test("a Server Component page re-renders with the destination's params", async ({ + page, + }) => { + await page.goto("/ja"); await expect(page.getByTestId("lang-page-lang")).toHaveText("ja"); + + // The destination's pre-rendered RSC chunk is fetched on soft + // navigation, so the Server Component output shows the live params. + await page.getByTestId("link-en").click(); + await expect(page.getByTestId("lang-page-lang")).toHaveText("en"); + + // Navigating back re-renders the build-time output of the loaded page. + await page.goBack(); + await expect(page.getByTestId("lang-page-lang")).toHaveText("ja"); + }); + + test("a Server Component layout and page under a dynamic segment update together", async ({ + page, + }) => { + await page.goto("/en/info"); + await expect(page.getByTestId("info-layout-lang")).toHaveText("en"); + await expect(page.getByTestId("info-page-lang")).toHaveText("en"); + + await page.getByTestId("link-ja-info").click(); + await expect(page.getByTestId("info-layout-lang")).toHaveText("ja"); + await expect(page.getByTestId("info-page-lang")).toHaveText("ja"); + }); + + test("navigates from a dynamic page into a nested Server Component page", async ({ + page, + }) => { + await page.goto("/ja"); + await page.getByTestId("link-en-info").click(); + await expect(page.getByTestId("info-layout-lang")).toHaveText("en"); + await expect(page.getByTestId("info-page-lang")).toHaveText("en"); + + await page.getByTestId("link-en-home").click(); + await expect(page.getByTestId("lang-page-lang")).toHaveText("en"); }); test("no JavaScript errors while navigating between dynamic pages", async ({ diff --git a/packages/static/src/fs-routes/entries.tsx b/packages/static/src/fs-routes/entries.tsx new file mode 100644 index 0000000..0c2918e --- /dev/null +++ b/packages/static/src/fs-routes/entries.tsx @@ -0,0 +1,50 @@ +import { FsRouteSlot } from "#rsc-client"; +import { rscPayloadDir } from "virtual:funstack/config"; +import { deferRegistry } from "../rsc/defer"; +import { getPayloadIDFor } from "../rsc/rscModule"; +import type { GetEntriesResult } from "../entryDefinition"; +import { + createFsRoutesEntriesWithHost, + type CreateFsRoutesOptions, + type FsRoutesRuntimeHost, +} from "./runtime"; + +/** + * The runtime host backed by the RSC environment: chunks are registered in + * the shared defer registry (served on demand in dev, written as + * content-hashed payload files at build), and Server Component route nodes + * 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; + }, + RouteSlot: FsRouteSlot, +}; + +/** + * Builds FUNSTACK Router state for file-system routing and returns a + * `getEntries` function (the default export expected by the `entries` plugin + * option). One entry is produced per statically-generated page. + * + * @experimental File-system routing is experimental and not yet subject to + * semantic versioning. Its API may change in a minor release. + * + * @example + * ```tsx + * // src/entries.tsx + * import { createFsRoutesEntries } from "@funstack/static/fs-routes"; + * import Root from "./root"; + * + * const modules = import.meta.glob("./pages/**\/*.{tsx,jsx}", { eager: true }); + * + * export default createFsRoutesEntries({ modules, base: "./pages", root: Root }); + * ``` + */ +export function createFsRoutesEntries( + options: CreateFsRoutesOptions, +): () => GetEntriesResult { + return createFsRoutesEntriesWithHost(options, rscRuntimeHost); +} diff --git a/packages/static/src/fs-routes/index.ts b/packages/static/src/fs-routes/index.ts index 173f073..1ccb574 100644 --- a/packages/static/src/fs-routes/index.ts +++ b/packages/static/src/fs-routes/index.ts @@ -18,4 +18,5 @@ export type { MaybePromise, } from "./types"; export { collectStaticPaths, urlPathToFilePath, type StaticPage } from "./tree"; -export { createFsRoutesEntries, type CreateFsRoutesOptions } from "./runtime"; +export { createFsRoutesEntries } from "./entries"; +export type { CreateFsRoutesOptions } from "./runtime"; diff --git a/packages/static/src/fs-routes/runtime.test.ts b/packages/static/src/fs-routes/runtime.test.ts index 467964f..8c5235d 100644 --- a/packages/static/src/fs-routes/runtime.test.ts +++ b/packages/static/src/fs-routes/runtime.test.ts @@ -1,8 +1,12 @@ -import { isValidElement } from "react"; +import { isValidElement, type ReactElement } from "react"; import { describe, expect, it } from "vitest"; -import { createFsRoutesEntries } from "./runtime"; +import { + createFsRoutesEntriesWithHost, + type FsRoutesRuntimeHost, +} from "./runtime"; import type { EntryDefinition } from "../entryDefinition"; import type { FsRouteComponentProps, FsRouteModule } from "./types"; +import type { FsRouteSlotProps } from "./slot"; function clientReference(): () => never { return Object.defineProperties( @@ -15,6 +19,34 @@ function clientReference(): () => never { const Root = ({ children }: { children: React.ReactNode }) => children; +const FakeSlot = (_props: FsRouteSlotProps): React.ReactNode => null; + +interface RegisteredChunk { + element: ReactElement; + name: string; + id: string; +} + +function fakeHost(): { + host: FsRoutesRuntimeHost; + registered: RegisteredChunk[]; +} { + const registered: RegisteredChunk[] = []; + const host: FsRoutesRuntimeHost = { + registerChunk(element, name) { + const id = `fun__rsc-payload/chunk-${registered.length}`; + registered.push({ + element: element as ReactElement, + name, + id, + }); + return id; + }, + RouteSlot: FakeSlot, + }; + return { host, registered }; +} + interface DefinitionLike { id?: string; path?: string; @@ -48,12 +80,16 @@ function collectDefinitions( async function entriesFor( modules: Record, + host: FsRoutesRuntimeHost = fakeHost().host, ): Promise { - const getEntries = createFsRoutesEntries({ - modules, - base: "./pages", - root: Root, - }); + const getEntries = createFsRoutesEntriesWithHost( + { + modules, + base: "./pages", + root: Root, + }, + host, + ); const entries: EntryDefinition[] = []; for await (const entry of getEntries()) { entries.push(entry); @@ -61,6 +97,13 @@ async function entriesFor( return entries; } +function slotOf(definition: DefinitionLike): FsRouteSlotProps { + expect(isValidElement(definition.component)).toBe(true); + const element = definition.component as ReactElement; + expect(element.type).toBe(FakeSlot); + return element.props; +} + describe("createFsRoutesEntries route definitions", () => { const clientLayout = clientReference(); const modules: Record = { @@ -79,16 +122,87 @@ describe("createFsRoutesEntries route definitions", () => { expect(layout.component).toBe(clientLayout); }); - it("renders a Server Component with build-time params and its route object", async () => { + it("wraps a Server Component in a slot with build-time output and its route object", async () => { const entries = await entriesFor(modules); const routes = routesOfEntry(entries.find((e) => e.path === "ja.html")!); const layout = routes.find((d) => d.path === "/:lang")!; const page = layout.children!.find((d) => d.path === "/")!; - expect(isValidElement(page.component)).toBe(true); - const props = (page.component as React.ReactElement) - .props; - expect(props.params).toEqual({ lang: "ja" }); - expect(props.route).toEqual({ id: page.id }); + const slot = slotOf(page); + expect(slot.route).toEqual({ id: page.id }); + expect(slot.paramNames).toEqual(["lang"]); + expect(slot.initialKey).toBe('["ja"]'); + expect(isValidElement(slot.initial)).toBe(true); + const initial = slot.initial as ReactElement; + expect(initial.props.params).toEqual({ lang: "ja" }); + expect(initial.props.route).toEqual({ id: page.id }); + }); + + it("registers one chunk per Server Component node per params combination", async () => { + const { host, registered } = fakeHost(); + const entries = await entriesFor(modules, host); + // [lang]/page for en and ja, about/page once; the client layout gets none. + expect(registered).toHaveLength(3); + const langChunks = registered.filter((r) => + r.name.startsWith("fs-route [lang]/page.tsx"), + ); + expect(langChunks.map((r) => r.element.props.params)).toEqual([ + { lang: "en" }, + { lang: "ja" }, + ]); + + // Every page's payload carries the same chunk map for a given node. + const slots = entries.map((entry) => { + const routes = routesOfEntry(entry); + const layout = routes.find((d) => d.path === "/:lang")!; + return slotOf(layout.children!.find((d) => d.path === "/")!); + }); + for (const slot of slots) { + expect(slot.chunks).toEqual({ + '["en"]': langChunks[0]!.id, + '["ja"]': langChunks[1]!.id, + }); + } + }); + + it("inlines build-time output only for nodes on the page's own chain", async () => { + const entries = await entriesFor(modules); + const routes = routesOfEntry(entries.find((e) => e.path === "en.html")!); + const about = routes.find((d) => d.path === "/about")!; + const slot = slotOf(about); + expect(slot.initialKey).toBeUndefined(); + expect(slot.initial).toBeUndefined(); + expect(Object.keys(slot.chunks)).toEqual(["[]"]); + }); + + it("restricts a Server Component layout's params to its own segments", async () => { + const { host, registered } = fakeHost(); + const serverLayout = () => null; + await entriesFor( + { + "./pages/[lang]/docs/layout.tsx": { default: serverLayout }, + "./pages/[lang]/docs/[slug]/page.tsx": { + default: () => null, + generateStaticParams: () => [ + { lang: "en", slug: "a" }, + { lang: "en", slug: "b" }, + { lang: "ja", slug: "a" }, + ], + }, + }, + host, + ); + const layoutChunks = registered.filter((r) => + r.name.startsWith("fs-route [lang]/docs/layout.tsx"), + ); + // One chunk per lang, shared by all slugs, with only the lang param. + expect(layoutChunks.map((r) => r.element.props.params)).toEqual([ + { lang: "en" }, + { lang: "ja" }, + ]); + const pageChunks = registered.filter((r) => + r.name.startsWith("fs-route [lang]/docs/[slug]/page.tsx"), + ); + expect(pageChunks).toHaveLength(3); }); it("assigns a unique id to every route definition", async () => { diff --git a/packages/static/src/fs-routes/runtime.tsx b/packages/static/src/fs-routes/runtime.tsx index 546317f..390dc6c 100644 --- a/packages/static/src/fs-routes/runtime.tsx +++ b/packages/static/src/fs-routes/runtime.tsx @@ -1,4 +1,4 @@ -import { createElement } from "react"; +import { createElement, type ComponentType, type ReactElement } from "react"; import { Router } from "@funstack/router"; import type { RouteDefinition } from "@funstack/router/server"; import type { @@ -13,10 +13,15 @@ import type { EntryDefinition, GetEntriesResult } from "../entryDefinition"; import { nextRoutes } from "./nextAdapter"; import { collectStaticPaths, + isDynamicSegment, modulesToRouteFiles, + paramName, + splitRoutePath, urlPathToFilePath, + type StaticPage, } from "./tree"; import { isClientReference } from "../util/clientReference"; +import { paramsKey, pickParams, type FsRouteSlotProps } from "./slot"; /** * Options for {@link createFsRoutesEntries}. @@ -54,53 +59,155 @@ export interface CreateFsRoutesOptions { adapter?: FsRoutesAdapter; } +/** + * Environment-specific services injected into the fs-routes runtime. The + * real host (attached by `createFsRoutesEntries` in `./entries`) uses the + * RSC runtime's defer registry; tests supply a fake to keep this module + * importable outside a Vite RSC environment. + */ +export interface FsRoutesRuntimeHost { + /** + * Registers a pre-rendered RSC chunk for a Server Component route node + * with one concrete params combination. Returns the payload ID under + * which the chunk is served (and baked into the slot's `chunks` map). + * `name` is a debugging label for build logs. + */ + registerChunk(element: ReactElement, name: string): string; + /** + * The client component standing in for Server Component route nodes, + * resolving the chunk for the current match's params. A client reference + * to `FsRouteSlot` from `#rsc-client` in the real host. + */ + RouteSlot: ComponentType; +} + +/** + * Per-node routing metadata shared by every generated page: the stable + * definition id, the dynamic params visible to the node, and (for Server + * Component nodes) the registered chunk for each generated params + * combination. + */ +interface NodeMeta { + id: string; + route: FsRouteObject; + /** Dynamic param names consumed by segments at or above this node. */ + paramNames: string[]; + /** + * Chunk payload ID by params key, for Server Component nodes. Filled by + * chunk registration before any page is yielded. + */ + chunks: Record; +} + +function buildNodeMetas( + nodes: FsRouteTreeNode[], + inheritedParamNames: string[], + idPrefix: string, + into: Map, +): void { + nodes.forEach((node, index) => { + // Unique id (by tree position) so that the route object passed to the + // component resolves to this route's context in the typed hooks; the + // file path is appended for legible debugging output. + const id = `${idPrefix}${index}${ + node.filePath === undefined ? "" : ` ${node.filePath}` + }`; + const ownParamNames = + node.path === undefined + ? [] + : splitRoutePath(node.path).filter(isDynamicSegment).map(paramName); + const paramNames = [...inheritedParamNames, ...ownParamNames]; + // The typed hooks resolve a route object by its runtime `id`; the + // branding symbol of `RouteHandle` is type-level only. + const route = { id } as unknown as FsRouteObject; + into.set(node, { id, route, paramNames, chunks: {} }); + if (node.children) { + buildNodeMetas(node.children, paramNames, `${idPrefix}${index}.`, into); + } + }); +} + +/** + * 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. + */ +function registerChunks( + pages: StaticPage[], + metas: Map, + host: FsRoutesRuntimeHost, +): void { + const combos = new Map< + FsRouteTreeNode, + Map> + >(); + for (const page of pages) { + for (const node of page.chain) { + const Component = node.module.default; + if (!Component || isClientReference(Component)) { + continue; + } + const meta = metas.get(node)!; + let nodeCombos = combos.get(node); + if (!nodeCombos) { + nodeCombos = new Map(); + combos.set(node, nodeCombos); + } + const key = paramsKey(meta.paramNames, page.params); + if (!nodeCombos.has(key)) { + nodeCombos.set(key, pickParams(page.params, meta.paramNames)); + } + } + } + for (const [node, nodeCombos] of combos) { + const meta = metas.get(node)!; + const Component = node.module + .default as ComponentType; + 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}`, + ); + } + } +} + /** * Builds FUNSTACK Router state for file-system routing and returns a * `getEntries` function (the default export expected by the `entries` plugin * option). One entry is produced per statically-generated page. * * The route tree is built once via the adapter; the router route definitions - * are rebuilt per page so that concrete dynamic `params` can be passed to the - * route components. - * - * @experimental File-system routing is experimental and not yet subject to - * semantic versioning. Its API may change in a minor release. - * - * @example - * ```tsx - * // src/entries.tsx - * import { createFsRoutesEntries } from "@funstack/static/fs-routes"; - * import Root from "./root"; + * are rebuilt per page so that the page's own Server Component output can be + * inlined into its payload. * - * const modules = import.meta.glob("./pages/**\/*.{tsx,jsx}", { eager: true }); - * - * export default createFsRoutesEntries({ modules, base: "./pages", root: Root }); - * ``` + * This is the host-parameterized implementation behind + * `createFsRoutesEntries` (see `./entries`), kept free of RSC-runtime + * imports so it stays testable outside a Vite environment. */ -export function createFsRoutesEntries( +export function createFsRoutesEntriesWithHost( options: CreateFsRoutesOptions, + host: FsRoutesRuntimeHost, ): () => GetEntriesResult { const { modules, base, root: Root, adapter = nextRoutes() } = options; function buildRouteDefinitions( nodes: FsRouteTreeNode[], - params: Record, - idPrefix: string, + metas: Map, + pageChain: Set, + pageParams: Record, ): RouteDefinition[] { - return nodes.map((node, index): RouteDefinition => { + return nodes.map((node): RouteDefinition => { + const meta = metas.get(node)!; const Component = node.module.default; - // Unique id (by tree position) so that the route object passed to the - // component resolves to this route's context in the typed hooks; the - // file path is appended for legible debugging output. - const id = `${idPrefix}${index}${ - node.filePath === undefined ? "" : ` ${node.filePath}` - }`; const definition: { id: string; path?: string; component?: React.ComponentType | React.ReactNode; children?: RouteDefinition[]; - } = { id }; + } = { id: meta.id }; if (node.path !== undefined) { definition.path = node.path; } @@ -113,23 +220,33 @@ export function createFsRoutesEntries( definition.component = Component as React.ComponentType; } else { // A Server Component crosses the RSC boundary only as its rendered - // output, so it must be rendered here with the build-time params. - // The route object lets Client Components below it read the live - // params through FUNSTACK Router's typed hooks. - // The typed hooks resolve a route object by its runtime `id`; the - // branding symbol of `RouteHandle` is type-level only. - const route = { id } as unknown as FsRouteObject; - definition.component = createElement( - Component as React.ComponentType, - { params, route }, - ); + // output, so a client slot stands in for it: it renders the + // build-time output while the current params match this page's, + // and fetches the destination's pre-rendered chunk after a soft + // client-side navigation. Output is inlined only for nodes this + // page renders through; other nodes always resolve via chunks. + const slotProps: FsRouteSlotProps = { + route: meta.route, + paramNames: meta.paramNames, + chunks: meta.chunks, + }; + if (pageChain.has(node)) { + const params = pickParams(pageParams, meta.paramNames); + slotProps.initialKey = paramsKey(meta.paramNames, pageParams); + slotProps.initial = createElement( + Component as React.ComponentType, + { params, route: meta.route }, + ); + } + definition.component = createElement(host.RouteSlot, slotProps); } } if (node.children) { definition.children = buildRouteDefinitions( node.children, - params, - `${idPrefix}${index}.`, + metas, + pageChain, + pageParams, ); } return definition; @@ -138,15 +255,24 @@ export function createFsRoutesEntries( function FsRoutesApp({ tree, - path, - params, + metas, + page, }: { tree: FsRouteTreeNode[]; - path: string; - params: Record; + metas: Map; + page: StaticPage; }): React.ReactNode { - const routes = buildRouteDefinitions(tree, params, ""); - return createElement(Router, { routes, fallback: "static", ssr: { path } }); + const routes = buildRouteDefinitions( + tree, + metas, + new Set(page.chain), + page.params, + ); + return createElement(Router, { + routes, + fallback: "static", + ssr: { path: page.urlPath }, + }); } return async function* getEntries(): AsyncGenerator { @@ -156,11 +282,14 @@ export function createFsRoutesEntries( const files = modulesToRouteFiles(modules, base, warn); const tree = adapter.buildRoutes(files); const pages = await collectStaticPaths(tree); - for (const { urlPath, params } of pages) { + const metas = new Map(); + buildNodeMetas(tree, [], "", metas); + registerChunks(pages, metas, host); + for (const page of pages) { yield { - path: urlPathToFilePath(urlPath), + path: urlPathToFilePath(page.urlPath), root: { default: Root }, - app: createElement(FsRoutesApp, { tree, path: urlPath, params }), + app: createElement(FsRoutesApp, { tree, metas, page }), }; } }; diff --git a/packages/static/src/fs-routes/slot.ts b/packages/static/src/fs-routes/slot.ts new file mode 100644 index 0000000..4031288 --- /dev/null +++ b/packages/static/src/fs-routes/slot.ts @@ -0,0 +1,69 @@ +import type { ReactNode } from "react"; +import type { FsRouteObject } from "./types"; + +/** + * Props of the internal client component (`FsRouteSlot` in `#rsc-client`) + * that stands in for a Server Component page or layout in the route + * definitions. All props are RSC-serializable so the slot can be baked into + * each page's payload. + * + * The slot renders `initial` while the current match's params equal the + * params this payload was built with, and otherwise fetches the pre-rendered + * RSC chunk for the current params from `chunks`. + */ +export interface FsRouteSlotProps { + /** Route object resolving to this route's context in the typed hooks. */ + route: FsRouteObject; + /** + * Names of the dynamic params consumed by segments at or above this + * node, in tree order. The current match's params are restricted to + * these names to identify the chunk to render. + */ + paramNames: string[]; + /** + * Pre-rendered RSC chunk payload IDs by params key (see + * {@link paramsKey}), covering every params combination this node was + * statically generated with. + */ + chunks: Record; + /** + * Params key this payload was built with. Present only when this node is + * on the generated page's own route chain; other nodes always resolve + * through `chunks` when navigated to. + */ + initialKey?: string; + /** Build-time rendered output for `initialKey`. */ + initial?: ReactNode; +} + +/** + * Serializes the params relevant to a route node into a stable string key. + * + * The key is the JSON array of the values of `paramNames`, in order — e.g. + * `["en"]` for `paramNames: ["lang"]` and `params: { lang: "en" }`. JSON + * escaping keeps values containing `/` (catch-all segments) or quotes + * unambiguous. A node with no dynamic params has the key `[]`. + */ +export function paramsKey( + paramNames: readonly string[], + params: Record, +): string { + return JSON.stringify(paramNames.map((name) => params[name] ?? null)); +} + +/** + * Restricts a params object to the given names (missing names are omitted). + */ +export function pickParams( + params: Record, + paramNames: readonly string[], +): Record { + const picked: Record = {}; + for (const name of paramNames) { + const value = params[name]; + if (value !== undefined) { + picked[name] = value; + } + } + return picked; +} diff --git a/packages/static/src/fs-routes/tree.test.ts b/packages/static/src/fs-routes/tree.test.ts index cc19655..c8e2d93 100644 --- a/packages/static/src/fs-routes/tree.test.ts +++ b/packages/static/src/fs-routes/tree.test.ts @@ -32,6 +32,12 @@ function clientPageModule(): FsRouteModule { }; } +function withoutChain( + pages: Awaited>, +): Array<{ urlPath: string; params: Record }> { + return pages.map(({ urlPath, params }) => ({ urlPath, params })); +} + describe("collectStaticPaths", () => { it("collects static pages, including index pages under a layout", async () => { const tree: FsRouteTreeNode[] = [ @@ -46,12 +52,27 @@ describe("collectStaticPaths", () => { }, ]; const pages = await collectStaticPaths(tree); - expect(pages).toEqual([ + expect(withoutChain(pages)).toEqual([ { urlPath: "/", params: {} }, { urlPath: "/about", params: {} }, ]); }); + it("records the route node chain of every page, root-first", async () => { + const page: FsRouteTreeNode = { path: "/", page: true, module: component }; + const layout: FsRouteTreeNode = { + path: "/dashboard", + page: false, + module: component, + children: [page], + }; + const pages = await collectStaticPaths([layout]); + expect(pages).toHaveLength(1); + expect(pages[0]!.chain).toHaveLength(2); + expect(pages[0]!.chain[0]).toBe(layout); + expect(pages[0]!.chain[1]).toBe(page); + }); + it("accumulates the path of a nested layout for its children", async () => { const tree: FsRouteTreeNode[] = [ { @@ -80,7 +101,7 @@ describe("collectStaticPaths", () => { }, ]; const pages = await collectStaticPaths(tree); - expect(pages).toEqual([ + expect(withoutChain(pages)).toEqual([ { urlPath: "/blog/hello", params: { slug: "hello" } }, { urlPath: "/blog/world", params: { slug: "world" } }, ]); @@ -95,7 +116,9 @@ describe("collectStaticPaths", () => { }, ]; const pages = await collectStaticPaths(tree); - expect(pages).toEqual([{ urlPath: "/u/1", params: { id: "1" } }]); + expect(withoutChain(pages)).toEqual([ + { urlPath: "/u/1", params: { id: "1" } }, + ]); }); it("substitutes catch-all values that contain slashes", async () => { @@ -107,7 +130,7 @@ describe("collectStaticPaths", () => { }, ]; const pages = await collectStaticPaths(tree); - expect(pages).toEqual([ + expect(withoutChain(pages)).toEqual([ { urlPath: "/docs/guide/intro", params: { slug: "guide/intro" } }, ]); }); @@ -142,7 +165,7 @@ describe("collectStaticPaths", () => { }, ]; const pages = await collectStaticPaths(tree); - expect(pages).toEqual([{ urlPath: "/about", params: {} }]); + expect(withoutChain(pages)).toEqual([{ urlPath: "/about", params: {} }]); }); it('explains that a "use client" page cannot export generateStaticParams', async () => { diff --git a/packages/static/src/fs-routes/tree.ts b/packages/static/src/fs-routes/tree.ts index 1df89e0..10faeb1 100644 --- a/packages/static/src/fs-routes/tree.ts +++ b/packages/static/src/fs-routes/tree.ts @@ -58,13 +58,19 @@ export interface StaticPage { urlPath: string; /** Resolved dynamic params for this page (empty for static routes). */ params: Record; + /** + * The route tree nodes this page renders through, root-first, ending with + * the page node itself. Contains the same node objects as the tree passed + * to {@link collectStaticPaths}. + */ + chain: FsRouteTreeNode[]; } /** * Splits a FUNSTACK Router path (e.g. `"/blog/:slug"`) into its non-empty * segments. A pathless or `"/"` path yields no segments. */ -function splitRoutePath(path: string): string[] { +export function splitRoutePath(path: string): string[] { return path.split("/").filter(Boolean); } @@ -84,14 +90,14 @@ function segmentsToUrl(segments: string[]): string { * Extracts the param name from a dynamic segment. * `":slug"` → `"slug"`, `":slug*"` (catch-all) → `"slug"`. */ -function paramName(segment: string): string { +export function paramName(segment: string): string { return segment.slice(1).replace(/\*$/, ""); } /** * Whether a router segment is dynamic (`:param` or catch-all `:param*`). */ -function isDynamicSegment(segment: string): boolean { +export function isDynamicSegment(segment: string): boolean { return segment.startsWith(":"); } @@ -107,11 +113,12 @@ async function addPagesForLeaf( module: FsRouteModule, pages: StaticPage[], filePath: string | undefined, + chain: FsRouteTreeNode[], ): Promise { const dynamicSegments = segments.filter(isDynamicSegment); if (dynamicSegments.length === 0) { - pages.push({ urlPath: segmentsToUrl(segments), params: {} }); + pages.push({ urlPath: segmentsToUrl(segments), params: {}, chain }); return; } @@ -147,24 +154,26 @@ async function addPagesForLeaf( } return value; }); - pages.push({ urlPath: segmentsToUrl(concreteSegments), params }); + pages.push({ urlPath: segmentsToUrl(concreteSegments), params, chain }); } } async function walk( nodes: FsRouteTreeNode[], prefixSegments: string[], + prefixChain: FsRouteTreeNode[], pages: StaticPage[], ): Promise { for (const node of nodes) { const ownSegments = node.path !== undefined ? splitRoutePath(node.path) : []; const segments = [...prefixSegments, ...ownSegments]; + const chain = [...prefixChain, node]; if (node.page) { - await addPagesForLeaf(segments, node.module, pages, node.filePath); + await addPagesForLeaf(segments, node.module, pages, node.filePath, chain); } if (node.children) { - await walk(node.children, segments, pages); + await walk(node.children, segments, chain, pages); } } } @@ -182,7 +191,7 @@ export async function collectStaticPaths( tree: FsRouteTreeNode[], ): Promise { const pages: StaticPage[] = []; - await walk(tree, [], pages); + await walk(tree, [], [], pages); return pages; } diff --git a/packages/static/src/rsc-client/entry.ts b/packages/static/src/rsc-client/entry.ts index 4c53ccf..0ac8f96 100644 --- a/packages/static/src/rsc-client/entry.ts +++ b/packages/static/src/rsc-client/entry.ts @@ -1,3 +1,4 @@ "use client"; export { RegistryContext, DeferredComponent } from "./clientWrapper"; +export { FsRouteSlot } from "./fsRouteSlot"; diff --git a/packages/static/src/rsc-client/fsRouteSlot.tsx b/packages/static/src/rsc-client/fsRouteSlot.tsx new file mode 100644 index 0000000..214489d --- /dev/null +++ b/packages/static/src/rsc-client/fsRouteSlot.tsx @@ -0,0 +1,107 @@ +import React from "react"; +import { useRouteParams } from "@funstack/router"; +import { DeferredComponent } from "./clientWrapper"; +import { paramsKey, type FsRouteSlotProps } from "../fs-routes/slot"; + +/** + * Stand-in for a Server Component page or layout under file-system routing. + * + * Rendered in place of the server element in the route definitions, it reads + * the live params of the current match through the route object and renders + * the pre-rendered RSC chunk for those params: the inline `initial` output + * for the params this payload was built with, or the chunk fetched from + * `chunks` after a soft client-side navigation to a sibling page of the same + * dynamic route. + */ +export function FsRouteSlot(props: FsRouteSlotProps): React.ReactNode { + const params = useRouteParams(props.route); + const key = paramsKey(props.paramNames, params); + if (key === props.initialKey) { + return props.initial; + } + const chunkId = props.chunks[key]; + // Remount the boundary per params key so an error for one destination + // does not stick to the next navigation. + return ( + + {chunkId === undefined ? ( + + ) : ( + + )} + + ); +} + +function MissingChunk(props: { paramsKey: string }): never { + throw new Error( + `No statically generated page exists for params ${props.paramsKey}. ` + + `Soft navigation can only render params enumerated by generateStaticParams().`, + ); +} + +/** + * Timestamp of the last hard-navigation fallback, kept in sessionStorage to + * break reload loops: a page that fails again right after a fallback reload + * surfaces the error instead of reloading forever. + */ +const reloadGuardKey = "funstack:fs-route-chunk-reload"; +const reloadGuardWindowMs = 10_000; + +interface FsRouteChunkBoundaryState { + error?: unknown; + surface?: boolean; +} + +/** + * Recovers from a failed chunk resolution (a params combination that was + * never generated, or a fetch failure — typically version skew after a + * redeploy removed the content-hashed chunk) by falling back to a hard + * navigation, which loads the destination's own HTML. The static build + * cannot produce this error, so recovery only ever runs in the browser. + */ +class FsRouteChunkBoundary extends React.Component< + { children: React.ReactNode }, + FsRouteChunkBoundaryState +> { + override state: FsRouteChunkBoundaryState = {}; + + static getDerivedStateFromError(error: unknown): FsRouteChunkBoundaryState { + return { error }; + } + + override componentDidCatch(error: unknown): void { + let lastReload = 0; + try { + lastReload = Number(sessionStorage.getItem(reloadGuardKey)) || 0; + } catch { + // sessionStorage unavailable: fall through with no guard record, + // reloading at most once more. + } + if (Date.now() - lastReload < reloadGuardWindowMs) { + this.setState({ surface: true }); + return; + } + try { + sessionStorage.setItem(reloadGuardKey, String(Date.now())); + } catch { + // Ignore; the guard read above degrades gracefully. + } + console.error( + "[funstack] Failed to load the RSC chunk for this navigation; falling back to a full page load.", + error, + ); + location.assign(location.href); + } + + override render(): React.ReactNode { + if (this.state.error !== undefined) { + if (this.state.surface) { + throw this.state.error; + } + // A hard navigation is underway; render nothing meanwhile. + return null; + } + return this.props.children; + } +}