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; + } +}