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