From 09f4e4cc30b2b12300a98f2391d31290d3a4ea94 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 15:30:48 +0000 Subject: [PATCH 1/2] feat: keep fs-route params live for Client Components and expose route objects Under file-system routing, all route components were baked into the RSC payload as pre-rendered elements, so dynamic params were frozen at the values of the initially-loaded page: soft client-side navigation between pages of the same dynamic route (e.g. /ja -> /en under [lang]) kept showing the old params everywhere, with no supported way to read the live ones. Two changes, per the discussion in #174: - Pass a Client Component page/layout to FUNSTACK Router as a component type instead of a pre-rendered element. Client references serialize through the RSC boundary, so the router renders them in the browser with the params of the current match; their `params` prop now stays live across soft navigation (along with the router's other route component props). - Assign a stable id to every generated route definition and pass Server Component pages/layouts a `route` prop: an opaque route object that can be forwarded to Client Components and given to the router's typed hooks (`useRouteParams(route)`) to read the live params of the URL currently shown. New types `FsRouteObject` and `FsRouteComponentProps` are exported from `@funstack/static/fs-routes`. A Server Component page's own rendered output still reflects its build-time params after soft navigation: static hosting serves one pre-rendered payload per page and nothing can re-run a Server Component in the browser. That remaining half of #174 needs on-navigation payload fetching and stays open. Refs #174 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CMwvxdFVc7J44PszMubjsv --- .../src/pages/learn/FileSystemRouting.mdx | 57 ++++++++- .../src/pages/[lang]/client/_page.tsx | 22 ++++ .../src/pages/[lang]/client/page.tsx | 6 + .../src/pages/[lang]/layout.tsx | 16 +++ .../src/pages/[lang]/live-lang.tsx | 14 +++ .../src/pages/[lang]/page.tsx | 30 +++++ .../static/e2e/tests-dev/fs-routing.spec.ts | 20 ++++ packages/static/e2e/tests/fs-routing.spec.ts | 63 ++++++++++ packages/static/src/fs-routes/index.ts | 2 + packages/static/src/fs-routes/runtime.test.ts | 113 ++++++++++++++++++ packages/static/src/fs-routes/runtime.tsx | 48 ++++++-- packages/static/src/fs-routes/tree.ts | 2 +- packages/static/src/fs-routes/types.ts | 47 +++++++- 13 files changed, 427 insertions(+), 13 deletions(-) create mode 100644 packages/static/e2e/fixture-fs-routing/src/pages/[lang]/client/_page.tsx create mode 100644 packages/static/e2e/fixture-fs-routing/src/pages/[lang]/client/page.tsx create mode 100644 packages/static/e2e/fixture-fs-routing/src/pages/[lang]/layout.tsx create mode 100644 packages/static/e2e/fixture-fs-routing/src/pages/[lang]/live-lang.tsx create mode 100644 packages/static/e2e/fixture-fs-routing/src/pages/[lang]/page.tsx create mode 100644 packages/static/src/fs-routes/runtime.test.ts diff --git a/packages/docs/src/pages/learn/FileSystemRouting.mdx b/packages/docs/src/pages/learn/FileSystemRouting.mdx index 11daafb..c2e6dbe 100644 --- a/packages/docs/src/pages/learn/FileSystemRouting.mdx +++ b/packages/docs/src/pages/learn/FileSystemRouting.mdx @@ -118,7 +118,7 @@ export default function BlogPost({ params }: { params: { slug: string } }) { } ``` -This generates `blog/hello.html` and `blog/world.html`. Each page component receives the resolved `params` as a prop. +This generates `blog/hello.html` and `blog/world.html`. Each page component receives the resolved `params` as a prop (see [Params on Client-Side Navigation](#params-on-client-side-navigation) for how `params` behaves when navigating in the browser). A dynamic route **must** export `generateStaticParams`; the build fails otherwise. A static site can only serve pages that were enumerated at build time, so a dynamic route without it would produce no output. @@ -132,7 +132,60 @@ export function generateStaticParams() { export { default } from "./_page"; // _page.tsx is marked "use client" ``` -> **Note:** Because static hosting serves one pre-rendered RSC payload per page, soft client-side navigation between different values of the _same_ dynamic route reflects the params of the initially-loaded page. Loading a dynamic URL directly always renders the correct params. Static routes and layouts navigate fully on the 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: + +- **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 — reflects the values the page was generated with. Because static hosting serves one pre-rendered RSC payload per page, nothing can re-render a Server Component with different params in the browser. + +### Reading Live Params with the Route Object + +Server Component pages and layouts additionally receive a `route` prop: an opaque **route object** identifying their route. Pass it to a Client Component and hand it to FUNSTACK Router's [`useRouteParams`](https://github.com/uhyo/funstack-router) hook to read the live params of the current URL: + +```tsx +// src/pages/blog/[slug]/page.tsx (a Server Component) +import type { FsRouteComponentProps } from "@funstack/static/fs-routes"; +import { LiveSlug } from "./live-slug"; + +export function generateStaticParams() { + return [{ slug: "hello" }, { slug: "world" }]; +} + +export default function BlogPost({ + params, + route, +}: FsRouteComponentProps<{ slug: string }>) { + return ( +
+ Post generated for: {params.slug} + +
+ ); +} +``` + +```tsx +// src/pages/blog/[slug]/live-slug.tsx +"use client"; +import { useRouteParams } from "@funstack/router"; +import type { FsRouteObject } from "@funstack/static/fs-routes"; + +export function LiveSlug({ + route, +}: { + route: FsRouteObject<{ slug: string }>; +}) { + const params = useRouteParams(route); // params of the URL currently shown + return

Now viewing: {params.slug}

; +} +``` + +The `FsRouteComponentProps` helper types both props of a Server Component page or layout. Like the `params` prop itself, the `Params` type argument is declared by you and not verified against the route's path. + +Client Components do not receive `route` — they don't need it, since FUNSTACK Router already passes them the live `params` directly (along with its other [route component props](https://github.com/uhyo/funstack-router)). + +> **Note:** The rest of a Server Component page's output still shows its build-time content after soft navigation to another param value of the same route. If a page must fully react to param changes on client-side navigation, make its body a Client Component (see the re-export pattern above). ## Custom Conventions (Adapters) diff --git a/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/client/_page.tsx b/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/client/_page.tsx new file mode 100644 index 0000000..7ab1655 --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/client/_page.tsx @@ -0,0 +1,22 @@ +"use client"; + +// A Client Component page: the router renders it with the live params of the +// current match, so it stays correct across soft client-side navigation. +export default function ClientLangPage({ + params, +}: { + params: { lang: string }; +}) { + return ( +
+

lang-client

+

{params.lang}

+ + English client page + {" "} + + Japanese client page + +
+ ); +} diff --git a/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/client/page.tsx b/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/client/page.tsx new file mode 100644 index 0000000..770f500 --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/client/page.tsx @@ -0,0 +1,6 @@ +// generateStaticParams runs at build time, so it lives in this Server +// Component module while the page body is a Client Component. +export function generateStaticParams() { + return [{ lang: "en" }, { lang: "ja" }]; +} +export { default } from "./_page"; diff --git a/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/layout.tsx b/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/layout.tsx new file mode 100644 index 0000000..7e3dfe9 --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/layout.tsx @@ -0,0 +1,16 @@ +"use client"; +import { Outlet, useLocation } from "@funstack/router"; + +// A Client Component layout under a dynamic segment: the router renders it +// with the params of the current match, so `params` stays live across soft +// client-side navigation. +export default function LangLayout({ params }: { params: { lang: string } }) { + const location = useLocation(); + return ( +
+

{location.pathname}

+

{params.lang}

+ +
+ ); +} diff --git a/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/live-lang.tsx b/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/live-lang.tsx new file mode 100644 index 0000000..b920869 --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/live-lang.tsx @@ -0,0 +1,14 @@ +"use client"; +import { useRouteParams } from "@funstack/router"; +import type { FsRouteObject } from "@funstack/static/fs-routes"; + +// A Client Component under a Server Component page reading the live params +// of the current URL through the route object. +export function LiveLang({ + route, +}: { + route: FsRouteObject<{ lang: string }>; +}) { + const params = useRouteParams(route); + return

{params.lang}

; +} 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 new file mode 100644 index 0000000..6ab1a4b --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/page.tsx @@ -0,0 +1,30 @@ +import type { FsRouteComponentProps } from "@funstack/static/fs-routes"; +import { LiveLang } from "./live-lang"; + +export function generateStaticParams() { + return [{ lang: "en" }, { lang: "ja" }]; +} + +// A Server Component page: rendered at build time with the concrete params, +// and given its route object to hand to Client Components for live params. +export default function LangPage({ + params, + route, +}: FsRouteComponentProps<{ lang: string }>) { + return ( +
+

lang

+

{params.lang}

+ + + English + {" "} + + Japanese + {" "} + + English client page + +
+ ); +} diff --git a/packages/static/e2e/tests-dev/fs-routing.spec.ts b/packages/static/e2e/tests-dev/fs-routing.spec.ts index 57227e4..4c3a65e 100644 --- a/packages/static/e2e/tests-dev/fs-routing.spec.ts +++ b/packages/static/e2e/tests-dev/fs-routing.spec.ts @@ -36,4 +36,24 @@ test.describe("File-system routing (dev server)", () => { await page.getByRole("link", { name: "About" }).click(); await expect(page.getByTestId("page-id")).toHaveText("about"); }); + + test("a Client Component layout receives live params on soft navigation", async ({ + page, + }) => { + await page.goto("/ja"); + await expect(page.getByTestId("lang-layout-lang")).toHaveText("ja"); + + await page.getByTestId("link-en").click(); + await expect(page.getByTestId("lang-layout-lang")).toHaveText("en"); + }); + + test("a Client Component reads live params via the route object", async ({ + page, + }) => { + await page.goto("/ja"); + await expect(page.getByTestId("live-lang")).toHaveText("ja"); + + await page.getByTestId("link-en").click(); + await expect(page.getByTestId("live-lang")).toHaveText("en"); + }); }); diff --git a/packages/static/e2e/tests/fs-routing.spec.ts b/packages/static/e2e/tests/fs-routing.spec.ts index 2b128ac..f3aa44f 100644 --- a/packages/static/e2e/tests/fs-routing.spec.ts +++ b/packages/static/e2e/tests/fs-routing.spec.ts @@ -11,6 +11,10 @@ test.describe("File-system routing build output", () => { "/blog/world", "/dashboard", "/dashboard/settings", + "/en", + "/ja", + "/en/client", + "/ja/client", ]) { const response = await request.get(path); expect(response.ok(), `expected ${path} to be served`).toBe(true); @@ -84,6 +88,17 @@ test.describe("File-system routing rendering", () => { await expect(page.getByTestId("page-id")).toHaveText("dashboard"); }); + test("renders correct params when a dynamic page is loaded directly", async ({ + page, + }) => { + for (const lang of ["en", "ja"]) { + await page.goto(`/${lang}`); + await expect(page.getByTestId("lang-layout-lang")).toHaveText(lang); + await expect(page.getByTestId("lang-page-lang")).toHaveText(lang); + await expect(page.getByTestId("live-lang")).toHaveText(lang); + } + }); + test("no JavaScript errors while navigating", async ({ page }) => { const errors: string[] = []; page.on("pageerror", (error) => { @@ -96,3 +111,51 @@ test.describe("File-system routing rendering", () => { expect(errors).toEqual([]); }); }); + +test.describe("Dynamic params on soft client-side navigation", () => { + test("a Client Component layout receives live params", async ({ page }) => { + await page.goto("/ja"); + await expect(page.getByTestId("lang-layout-lang")).toHaveText("ja"); + + await page.getByTestId("link-en").click(); + await expect(page.getByTestId("lang-layout-pathname")).toHaveText("/en"); + await expect(page.getByTestId("lang-layout-lang")).toHaveText("en"); + }); + + test("a Client Component page receives live params", async ({ page }) => { + await page.goto("/en/client"); + await expect(page.getByTestId("client-page-lang")).toHaveText("en"); + + await page.getByTestId("link-ja-client").click(); + await expect(page.getByTestId("client-page-lang")).toHaveText("ja"); + }); + + test("a Client Component reads live params via the route object under a Server Component page", async ({ + page, + }) => { + await page.goto("/ja"); + await expect(page.getByTestId("live-lang")).toHaveText("ja"); + + 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. + await expect(page.getByTestId("lang-page-lang")).toHaveText("ja"); + }); + + test("no JavaScript errors while navigating between dynamic pages", async ({ + page, + }) => { + const errors: string[] = []; + page.on("pageerror", (error) => { + errors.push(error.message); + }); + await page.goto("/ja"); + await page.waitForLoadState("networkidle"); + await page.getByTestId("link-en").click(); + await expect(page.getByTestId("lang-layout-lang")).toHaveText("en"); + await page.getByTestId("link-en-client").click(); + await expect(page.getByTestId("client-page-lang")).toHaveText("en"); + expect(errors).toEqual([]); + }); +}); diff --git a/packages/static/src/fs-routes/index.ts b/packages/static/src/fs-routes/index.ts index 35bb34b..173f073 100644 --- a/packages/static/src/fs-routes/index.ts +++ b/packages/static/src/fs-routes/index.ts @@ -9,8 +9,10 @@ export { nextRoutes, type NextRoutesOptions } from "./nextAdapter"; export type { FsRoutesAdapter, + FsRouteComponentProps, FsRouteFile, FsRouteModule, + FsRouteObject, FsRouteTreeNode, FsRootComponent, MaybePromise, diff --git a/packages/static/src/fs-routes/runtime.test.ts b/packages/static/src/fs-routes/runtime.test.ts new file mode 100644 index 0000000..467964f --- /dev/null +++ b/packages/static/src/fs-routes/runtime.test.ts @@ -0,0 +1,113 @@ +import { isValidElement } from "react"; +import { describe, expect, it } from "vitest"; +import { createFsRoutesEntries } from "./runtime"; +import type { EntryDefinition } from "../entryDefinition"; +import type { FsRouteComponentProps, FsRouteModule } from "./types"; + +function clientReference(): () => never { + return Object.defineProperties( + (): never => { + throw new Error("Unexpectedly client reference is called on server"); + }, + { $$typeof: { value: Symbol.for("react.client.reference") } }, + ); +} + +const Root = ({ children }: { children: React.ReactNode }) => children; + +interface DefinitionLike { + id?: string; + path?: string; + component?: unknown; + children?: DefinitionLike[]; +} + +/** + * Renders an entry's `FsRoutesApp` element (a plain function component) to + * obtain the Router element and returns its route definitions. + */ +function routesOfEntry(entry: EntryDefinition): DefinitionLike[] { + const app = entry.app as React.ReactElement & { + type: (props: object) => React.ReactElement<{ routes: DefinitionLike[] }>; + }; + return app.type(app.props).props.routes; +} + +function collectDefinitions( + definitions: DefinitionLike[], + into: DefinitionLike[] = [], +): DefinitionLike[] { + for (const definition of definitions) { + into.push(definition); + if (definition.children) { + collectDefinitions(definition.children, into); + } + } + return into; +} + +async function entriesFor( + modules: Record, +): Promise { + const getEntries = createFsRoutesEntries({ + modules, + base: "./pages", + root: Root, + }); + const entries: EntryDefinition[] = []; + for await (const entry of getEntries()) { + entries.push(entry); + } + return entries; +} + +describe("createFsRoutesEntries route definitions", () => { + const clientLayout = clientReference(); + const modules: Record = { + "./pages/[lang]/layout.tsx": { default: clientLayout }, + "./pages/[lang]/page.tsx": { + default: () => null, + generateStaticParams: () => [{ lang: "en" }, { lang: "ja" }], + }, + "./pages/about/page.tsx": { default: () => null }, + }; + + it("passes a Client Component as a component type so the router renders it with live params", async () => { + const entries = await entriesFor(modules); + const routes = routesOfEntry(entries.find((e) => e.path === "en.html")!); + const layout = routes.find((d) => d.path === "/:lang")!; + expect(layout.component).toBe(clientLayout); + }); + + it("renders a Server Component with build-time params 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 }); + }); + + it("assigns a unique id to every route definition", async () => { + const entries = await entriesFor(modules); + for (const entry of entries) { + const definitions = collectDefinitions(routesOfEntry(entry)); + const ids = definitions.map((d) => d.id); + expect(ids.every((id) => typeof id === "string" && id !== "")).toBe(true); + expect(new Set(ids).size).toBe(ids.length); + } + }); + + it("assigns the same ids on every page so route objects stay portable", async () => { + const entries = await entriesFor(modules); + const idsPerEntry = entries.map((entry) => + collectDefinitions(routesOfEntry(entry)).map((d) => `${d.path} ${d.id}`), + ); + for (const ids of idsPerEntry) { + expect(ids).toEqual(idsPerEntry[0]); + } + }); +}); diff --git a/packages/static/src/fs-routes/runtime.tsx b/packages/static/src/fs-routes/runtime.tsx index a8ecff2..b8802c2 100644 --- a/packages/static/src/fs-routes/runtime.tsx +++ b/packages/static/src/fs-routes/runtime.tsx @@ -3,7 +3,9 @@ import { Router } from "@funstack/router"; import type { RouteDefinition } from "@funstack/router/server"; import type { FsRootComponent, + FsRouteComponentProps, FsRouteModule, + FsRouteObject, FsRoutesAdapter, FsRouteTreeNode, } from "./types"; @@ -11,6 +13,7 @@ import type { EntryDefinition, GetEntriesResult } from "../entryDefinition"; import { nextRoutes } from "./nextAdapter"; import { collectStaticPaths, + isClientReference, modulesToRouteFiles, urlPathToFilePath, } from "./tree"; @@ -82,25 +85,52 @@ export function createFsRoutesEntries( function buildRouteDefinitions( nodes: FsRouteTreeNode[], params: Record, + idPrefix: string, ): RouteDefinition[] { - return nodes.map((node): RouteDefinition => { + return nodes.map((node, index): RouteDefinition => { 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.ReactNode; + component?: React.ComponentType | React.ReactNode; children?: RouteDefinition[]; - } = {}; + } = { id }; if (node.path !== undefined) { definition.path = node.path; } if (Component) { - definition.component = createElement( - Component as React.ComponentType<{ params: Record }>, - { params }, - ); + if (isClientReference(Component)) { + // A Client Component crosses the RSC boundary as a reference, so + // the router can render it in the browser. Pass the component + // itself so it receives the params of the current match, keeping + // them live across soft client-side navigation. + 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 `PartialRouteDefinition` is type-level only. + const route = { id } as unknown as FsRouteObject; + definition.component = createElement( + Component as React.ComponentType, + { params, route }, + ); + } } if (node.children) { - definition.children = buildRouteDefinitions(node.children, params); + definition.children = buildRouteDefinitions( + node.children, + params, + `${idPrefix}${index}.`, + ); } return definition; }); @@ -115,7 +145,7 @@ export function createFsRoutesEntries( path: string; params: Record; }): React.ReactNode { - const routes = buildRouteDefinitions(tree, params); + const routes = buildRouteDefinitions(tree, params, ""); return createElement(Router, { routes, fallback: "static", ssr: { path } }); } diff --git a/packages/static/src/fs-routes/tree.ts b/packages/static/src/fs-routes/tree.ts index 0108c4f..6fa9ed1 100644 --- a/packages/static/src/fs-routes/tree.ts +++ b/packages/static/src/fs-routes/tree.ts @@ -101,7 +101,7 @@ const CLIENT_REFERENCE = Symbol.for("react.client.reference"); * `"use client"`. React's `registerClientReference` tags every such export * with `$$typeof`. */ -function isClientReference(value: unknown): boolean { +export function isClientReference(value: unknown): boolean { return ( typeof value === "function" && "$$typeof" in value && diff --git a/packages/static/src/fs-routes/types.ts b/packages/static/src/fs-routes/types.ts index 9872ac2..bb8b849 100644 --- a/packages/static/src/fs-routes/types.ts +++ b/packages/static/src/fs-routes/types.ts @@ -1,7 +1,49 @@ +import type { PartialRouteDefinition } from "@funstack/router/server"; import type { ComponentType, ReactNode } from "react"; export type MaybePromise = T | Promise; +/** + * Opaque route object identifying the route of a page or layout. + * + * Passed to Server Component pages and layouts as the `route` prop. Pass it + * to a Client Component and give it to FUNSTACK Router's typed hooks + * (`useRouteParams(route)`) to read the params of the URL currently shown, + * which may differ from the build-time `params` prop after soft client-side + * navigation between pages of the same dynamic route. + * + * The `Params` type argument describes the route's dynamic params, like the + * `params` prop it is not verified against the route's actual path. + * + * @experimental File-system routing is experimental and not yet subject to + * semantic versioning. + */ +export type FsRouteObject< + Params extends Record = Record, +> = PartialRouteDefinition; + +/** + * Props received by a Server Component page or layout under file-system + * routing: the concrete `params` the page was generated with, and the + * {@link FsRouteObject | route object} identifying its route. + * + * Client Component pages and layouts do not receive `route`; FUNSTACK Router + * renders them directly with the live `params` of the current match (along + * with its other route component props), so they stay correct across soft + * client-side navigation on their own. + * + * @experimental File-system routing is experimental and not yet subject to + * semantic versioning. + */ +export interface FsRouteComponentProps< + Params extends Record = Record, +> { + /** Dynamic route params. For a Server Component, the build-time values. */ + params: Params; + /** Opaque route object for FUNSTACK Router's typed hooks. */ + route: FsRouteObject; +} + /** * Module shape for a discovered route file (a page or a layout). * @@ -11,7 +53,10 @@ export type MaybePromise = T | Promise; */ export interface FsRouteModule { /** The component for this page or layout. */ - default?: ComponentType<{ params: Record }> | ComponentType; + default?: + | ComponentType + | ComponentType<{ params: Record }> + | ComponentType; /** * Function used to statically generate a dynamic route. Required for pages * whose route contains a dynamic segment; the build fails without it, since From 4eb9ba481dc8ad72bc215c1fe0f8440c1c8448ae Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 14:30:49 +0000 Subject: [PATCH 2/2] feat: adopt FUNSTACK Router 1.4 route handles for fs-routes route objects Address review feedback on #176: - Update @funstack/router to ^1.4.0 (dev/peer/fixture/docs deps). The router now passes component-typed routes its route definition as a `route` prop, so Client Component pages and layouts receive a route object from the router itself and the fs-routes `route` prop is uniform across Server and Client Components. The Client Component fixture page and e2e now cover useRouteParams(route) with the router-provided prop. - Retype FsRouteObject on the router's new RouteHandle (the declared type of the `route` prop) instead of PartialRouteDefinition, and update FsRouteComponentProps docs for the uniform behavior. - Move isClientReference into a shared util module (src/util/clientReference.ts). - Docs: present the Server Component staleness as a temporary limitation being worked on (#174), and document that Client Components receive `route` from FUNSTACK Router v1.4+. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CMwvxdFVc7J44PszMubjsv --- packages/docs/package.json | 2 +- .../src/pages/learn/FileSystemRouting.mdx | 10 ++-- packages/example-fs-routing/package.json | 2 +- .../e2e/fixture-fs-routing/package.json | 2 +- .../src/pages/[lang]/client/_page.tsx | 12 ++-- packages/static/e2e/tests/fs-routing.spec.ts | 6 +- packages/static/package.json | 4 +- packages/static/src/fs-routes/runtime.tsx | 4 +- packages/static/src/fs-routes/tree.ts | 16 +----- packages/static/src/fs-routes/types.ts | 38 +++++++------ packages/static/src/util/clientReference.ts | 14 +++++ pnpm-lock.yaml | 56 ++++++++++--------- 12 files changed, 88 insertions(+), 78 deletions(-) create mode 100644 packages/static/src/util/clientReference.ts diff --git a/packages/docs/package.json b/packages/docs/package.json index 1b10df9..e4b1c3f 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -11,7 +11,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@funstack/router": "^1.2.0", + "@funstack/router": "^1.4.0", "@funstack/static": "workspace:*", "@shikijs/rehype": "^4.4.3", "@types/node": "catalog:", diff --git a/packages/docs/src/pages/learn/FileSystemRouting.mdx b/packages/docs/src/pages/learn/FileSystemRouting.mdx index c2e6dbe..1205af8 100644 --- a/packages/docs/src/pages/learn/FileSystemRouting.mdx +++ b/packages/docs/src/pages/learn/FileSystemRouting.mdx @@ -137,11 +137,11 @@ export { default } from "./_page"; // _page.tsx is marked "use client" 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: - **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 — reflects the values the page was generated with. Because static hosting serves one pre-rendered RSC payload per page, nothing can re-render a Server Component with different params in the browser. +- **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. ### Reading Live Params with the Route Object -Server Component pages and layouts additionally receive a `route` prop: an opaque **route object** identifying their route. Pass it to a Client Component and hand it to FUNSTACK Router's [`useRouteParams`](https://github.com/uhyo/funstack-router) hook to read the live params of the current URL: +Every page and layout receives a `route` prop: an opaque **route object** identifying its route. In a Client Component, hand it to FUNSTACK Router's [`useRouteParams`](https://github.com/uhyo/funstack-router) hook to read the live params of the current URL. A Server Component cannot use hooks itself, but can forward the prop to a Client Component: ```tsx // src/pages/blog/[slug]/page.tsx (a Server Component) @@ -181,11 +181,9 @@ export function LiveSlug({ } ``` -The `FsRouteComponentProps` helper types both props of a Server Component page or layout. Like the `params` prop itself, the `Params` type argument is declared by you and not verified against the route's path. +The `FsRouteComponentProps` helper types the `params` and `route` props any page or layout receives. Like the `params` prop itself, the `Params` type argument is declared by you and not verified against the route's path. -Client Components do not receive `route` — they don't need it, since FUNSTACK Router already passes them the live `params` directly (along with its other [route component props](https://github.com/uhyo/funstack-router)). - -> **Note:** The rest of a Server Component page's output still shows its build-time content after soft navigation to another param value of the same route. If a page must fully react to param changes on client-side navigation, make its body a Client Component (see the re-export pattern above). +The `route` prop reaches the two kinds of components differently, with the same result: FUNSTACK Static passes it to Server Components at build time, while FUNSTACK Router (v1.4.0 or later) passes it to Client Components at render time — along with the live `params` and its other [route component props](https://github.com/uhyo/funstack-router). ## Custom Conventions (Adapters) diff --git a/packages/example-fs-routing/package.json b/packages/example-fs-routing/package.json index d9aacb3..41c08fc 100644 --- a/packages/example-fs-routing/package.json +++ b/packages/example-fs-routing/package.json @@ -10,7 +10,7 @@ "preview": "vite preview" }, "dependencies": { - "@funstack/router": "^1.2.0", + "@funstack/router": "^1.4.0", "@funstack/static": "workspace:*", "@types/node": "catalog:", "react": "catalog:", diff --git a/packages/static/e2e/fixture-fs-routing/package.json b/packages/static/e2e/fixture-fs-routing/package.json index 6683ba8..a94d235 100644 --- a/packages/static/e2e/fixture-fs-routing/package.json +++ b/packages/static/e2e/fixture-fs-routing/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "devDependencies": { - "@funstack/router": "^1.2.0", + "@funstack/router": "^1.4.0", "@funstack/static": "workspace:*", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", diff --git a/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/client/_page.tsx b/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/client/_page.tsx index 7ab1655..448e457 100644 --- a/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/client/_page.tsx +++ b/packages/static/e2e/fixture-fs-routing/src/pages/[lang]/client/_page.tsx @@ -1,16 +1,20 @@ "use client"; +import { useRouteParams } from "@funstack/router"; +import type { FsRouteComponentProps } from "@funstack/static/fs-routes"; // A Client Component page: the router renders it with the live params of the -// current match, so it stays correct across soft client-side navigation. +// current match (and its route object), so it stays correct across soft +// client-side navigation. export default function ClientLangPage({ params, -}: { - params: { lang: string }; -}) { + route, +}: FsRouteComponentProps<{ lang: string }>) { + const liveParams = useRouteParams(route); return (

lang-client

{params.lang}

+

{liveParams.lang}

English client page {" "} diff --git a/packages/static/e2e/tests/fs-routing.spec.ts b/packages/static/e2e/tests/fs-routing.spec.ts index f3aa44f..d0f1165 100644 --- a/packages/static/e2e/tests/fs-routing.spec.ts +++ b/packages/static/e2e/tests/fs-routing.spec.ts @@ -122,12 +122,16 @@ test.describe("Dynamic params on soft client-side navigation", () => { await expect(page.getByTestId("lang-layout-lang")).toHaveText("en"); }); - test("a Client Component page receives live params", async ({ page }) => { + test("a Client Component page receives live params and its route object", async ({ + page, + }) => { await page.goto("/en/client"); await expect(page.getByTestId("client-page-lang")).toHaveText("en"); + await expect(page.getByTestId("client-page-hook-lang")).toHaveText("en"); await page.getByTestId("link-ja-client").click(); await expect(page.getByTestId("client-page-lang")).toHaveText("ja"); + await expect(page.getByTestId("client-page-hook-lang")).toHaveText("ja"); }); test("a Client Component reads live params via the route object under a Server Component page", async ({ diff --git a/packages/static/package.json b/packages/static/package.json index acf63f8..7bf5cb9 100644 --- a/packages/static/package.json +++ b/packages/static/package.json @@ -64,7 +64,7 @@ "author": "uhyo ", "license": "MIT", "devDependencies": { - "@funstack/router": "^1.2.0", + "@funstack/router": "^1.4.0", "@playwright/test": "^1.62.1", "@types/node": "catalog:", "@types/react": "^19.2.18", @@ -84,7 +84,7 @@ "srvx": "^0.12.5" }, "peerDependencies": { - "@funstack/router": "^1.2.0", + "@funstack/router": "^1.4.0", "react": "^19.2.3", "react-dom": "^19.2.3", "vite": "^7.0.0 || ^8.0.0" diff --git a/packages/static/src/fs-routes/runtime.tsx b/packages/static/src/fs-routes/runtime.tsx index b8802c2..546317f 100644 --- a/packages/static/src/fs-routes/runtime.tsx +++ b/packages/static/src/fs-routes/runtime.tsx @@ -13,10 +13,10 @@ import type { EntryDefinition, GetEntriesResult } from "../entryDefinition"; import { nextRoutes } from "./nextAdapter"; import { collectStaticPaths, - isClientReference, modulesToRouteFiles, urlPathToFilePath, } from "./tree"; +import { isClientReference } from "../util/clientReference"; /** * Options for {@link createFsRoutesEntries}. @@ -117,7 +117,7 @@ export function createFsRoutesEntries( // 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 `PartialRouteDefinition` is type-level only. + // branding symbol of `RouteHandle` is type-level only. const route = { id } as unknown as FsRouteObject; definition.component = createElement( Component as React.ComponentType, diff --git a/packages/static/src/fs-routes/tree.ts b/packages/static/src/fs-routes/tree.ts index 6fa9ed1..1df89e0 100644 --- a/packages/static/src/fs-routes/tree.ts +++ b/packages/static/src/fs-routes/tree.ts @@ -1,3 +1,4 @@ +import { isClientReference } from "../util/clientReference"; import type { FsRouteFile, FsRouteModule, FsRouteTreeNode } from "./types"; /** @@ -94,21 +95,6 @@ function isDynamicSegment(segment: string): boolean { return segment.startsWith(":"); } -const CLIENT_REFERENCE = Symbol.for("react.client.reference"); - -/** - * Whether a module export is a client reference, meaning the module is marked - * `"use client"`. React's `registerClientReference` tags every such export - * with `$$typeof`. - */ -export function isClientReference(value: unknown): boolean { - return ( - typeof value === "function" && - "$$typeof" in value && - value.$$typeof === CLIENT_REFERENCE - ); -} - /** * Formats the source file of a route for an error message, when known. */ diff --git a/packages/static/src/fs-routes/types.ts b/packages/static/src/fs-routes/types.ts index bb8b849..bfefa43 100644 --- a/packages/static/src/fs-routes/types.ts +++ b/packages/static/src/fs-routes/types.ts @@ -1,18 +1,17 @@ -import type { PartialRouteDefinition } from "@funstack/router/server"; +import type { RouteHandle } from "@funstack/router"; import type { ComponentType, ReactNode } from "react"; export type MaybePromise = T | Promise; /** - * Opaque route object identifying the route of a page or layout. + * Opaque route object identifying the route of a page or layout, received as + * the `route` prop. Give it to FUNSTACK Router's typed hooks + * (`useRouteParams(route)`, in a Client Component) to read the params of the + * URL currently shown, which may differ from a Server Component's build-time + * `params` prop after soft client-side navigation between pages of the same + * dynamic route. * - * Passed to Server Component pages and layouts as the `route` prop. Pass it - * to a Client Component and give it to FUNSTACK Router's typed hooks - * (`useRouteParams(route)`) to read the params of the URL currently shown, - * which may differ from the build-time `params` prop after soft client-side - * navigation between pages of the same dynamic route. - * - * The `Params` type argument describes the route's dynamic params, like the + * The `Params` type argument describes the route's dynamic params; like the * `params` prop it is not verified against the route's actual path. * * @experimental File-system routing is experimental and not yet subject to @@ -20,17 +19,17 @@ export type MaybePromise = T | Promise; */ export type FsRouteObject< Params extends Record = Record, -> = PartialRouteDefinition; +> = RouteHandle; /** - * Props received by a Server Component page or layout under file-system - * routing: the concrete `params` the page was generated with, and the - * {@link FsRouteObject | route object} identifying its route. + * Props received by every page or layout under file-system routing: the + * route's dynamic `params`, and the {@link FsRouteObject | route object} + * identifying its route. * - * Client Component pages and layouts do not receive `route`; FUNSTACK Router - * renders them directly with the live `params` of the current match (along - * with its other route component props), so they stay correct across soft - * client-side navigation on their own. + * A Server Component receives exactly these props, with the `params` the + * page was generated with. A Client Component is rendered by FUNSTACK Router + * with the live `params` of the current match, plus the router's other route + * component props; these two are the subset shared by both kinds. * * @experimental File-system routing is experimental and not yet subject to * semantic versioning. @@ -38,7 +37,10 @@ export type FsRouteObject< export interface FsRouteComponentProps< Params extends Record = Record, > { - /** Dynamic route params. For a Server Component, the build-time values. */ + /** + * Dynamic route params. Build-time values for a Server Component; live + * values for a Client Component. + */ params: Params; /** Opaque route object for FUNSTACK Router's typed hooks. */ route: FsRouteObject; diff --git a/packages/static/src/util/clientReference.ts b/packages/static/src/util/clientReference.ts new file mode 100644 index 0000000..49cc812 --- /dev/null +++ b/packages/static/src/util/clientReference.ts @@ -0,0 +1,14 @@ +const CLIENT_REFERENCE = Symbol.for("react.client.reference"); + +/** + * Whether a module export is a client reference, meaning the module is marked + * `"use client"`. React's `registerClientReference` tags every such export + * with `$$typeof`. + */ +export function isClientReference(value: unknown): boolean { + return ( + typeof value === "function" && + "$$typeof" in value && + value.$$typeof === CLIENT_REFERENCE + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 521667a..161e7a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,8 +42,8 @@ importers: packages/docs: dependencies: '@funstack/router': - specifier: ^1.2.0 - version: 1.2.0(react@19.2.8) + specifier: ^1.4.0 + version: 1.4.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@funstack/static': specifier: workspace:* version: link:../static @@ -125,8 +125,8 @@ importers: packages/example-fs-routing: dependencies: '@funstack/router': - specifier: ^1.2.0 - version: 1.2.0(react@19.2.8) + specifier: ^1.4.0 + version: 1.4.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@funstack/static': specifier: workspace:* version: link:../static @@ -172,8 +172,8 @@ importers: version: 0.12.5 devDependencies: '@funstack/router': - specifier: ^1.2.0 - version: 1.2.0(react@19.2.8) + specifier: ^1.4.0 + version: 1.4.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@playwright/test': specifier: ^1.62.1 version: 1.62.1 @@ -232,8 +232,8 @@ importers: packages/static/e2e/fixture-fs-routing: devDependencies: '@funstack/router': - specifier: ^1.2.0 - version: 1.2.0(react@19.2.8) + specifier: ^1.4.0 + version: 1.4.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@funstack/static': specifier: workspace:* version: link:../.. @@ -383,8 +383,8 @@ packages: '@csstools/css-parser-algorithms': ^4.0.0 '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-color-parser@4.2.0': - resolution: {integrity: sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==} + '@csstools/css-color-parser@4.2.1': + resolution: {integrity: sha512-YpAJZhaHplYQkG8ib+/Fx5Y0eF2lVWi3tIvMJA6i39TLyUNp2439cifzW8VMjhlqrBjHzK5hVGugRRm2zTKI/A==} engines: {node: '>=20.19.0'} peerDependencies: '@csstools/css-parser-algorithms': ^4.0.0 @@ -396,8 +396,8 @@ packages: peerDependencies: '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.1.8': - resolution: {integrity: sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==} + '@csstools/css-syntax-patches-for-csstree@1.1.9': + resolution: {integrity: sha512-iGGw4OsAYsS6pD29MdJ2bX/nJx65a04ZZiw6x+VwWlP2DdXf6f++Zmuv/OzALpdyfVhjbduIIF2cXM7HWBIe9A==} peerDependencies: css-tree: ^3.2.1 peerDependenciesMeta: @@ -750,11 +750,12 @@ packages: '@noble/hashes': optional: true - '@funstack/router@1.2.0': - resolution: {integrity: sha512-Y2OLyxcqx+TaijRa/MyA2ue7bk5svIPhjx/yvKHeQ1gB6O7gdI+D5gnuWn4W6zLuGmAXeopWd9Qco1AdnPWwQQ==} + '@funstack/router@1.4.0': + resolution: {integrity: sha512-hGBtHzNGWiCWzM+THX30oMk4wIzpfEVuIjFbh7vDz2F2gOZhHh+pbUB86VxxbWj+mLoadxts7d0JLDVK3IFRNw==} hasBin: true peerDependencies: react: ^19.2.0 + react-dom: ^19.2.0 '@funstack/skill-installer@1.1.0': resolution: {integrity: sha512-reB4V8FE8foqDULiqOhrlMq5ZqrdKpqlM8ulU/gtklf9OW71O4w5z5EZWjzygmIthmTriDo745EcRGm5+DQR1w==} @@ -2764,11 +2765,11 @@ packages: resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} - tldts-core@7.4.10: - resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==} + tldts-core@7.4.11: + resolution: {integrity: sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==} - tldts@7.4.10: - resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==} + tldts@7.4.11: + resolution: {integrity: sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==} hasBin: true tough-cookie@6.0.2: @@ -3068,7 +3069,7 @@ snapshots: dependencies: '@asamuzakjp/generational-cache': 1.0.1 '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-color-parser': 4.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 optional: true @@ -3129,7 +3130,7 @@ snapshots: '@csstools/css-tokenizer': 4.0.0 optional: true - '@csstools/css-color-parser@4.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-color-parser@4.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/color-helpers': 6.1.1 '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) @@ -3142,7 +3143,7 @@ snapshots: '@csstools/css-tokenizer': 4.0.0 optional: true - '@csstools/css-syntax-patches-for-csstree@1.1.8(css-tree@3.2.1)': + '@csstools/css-syntax-patches-for-csstree@1.1.9(css-tree@3.2.1)': optionalDependencies: css-tree: 3.2.1 optional: true @@ -3346,10 +3347,11 @@ snapshots: '@exodus/bytes@1.15.1': optional: true - '@funstack/router@1.2.0(react@19.2.8)': + '@funstack/router@1.4.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@funstack/skill-installer': 1.1.0 react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) '@funstack/skill-installer@1.1.0': {} @@ -4520,7 +4522,7 @@ snapshots: '@asamuzakjp/css-color': 5.1.11 '@asamuzakjp/dom-selector': 7.1.1 '@bramus/specificity': 2.4.2 - '@csstools/css-syntax-patches-for-csstree': 1.1.8(css-tree@3.2.1) + '@csstools/css-syntax-patches-for-csstree': 1.1.9(css-tree@3.2.1) '@exodus/bytes': 1.15.1 css-tree: 3.2.1 data-urls: 7.0.0 @@ -5334,17 +5336,17 @@ snapshots: tinyrainbow@3.1.1: {} - tldts-core@7.4.10: + tldts-core@7.4.11: optional: true - tldts@7.4.10: + tldts@7.4.11: dependencies: - tldts-core: 7.4.10 + tldts-core: 7.4.11 optional: true tough-cookie@6.0.2: dependencies: - tldts: 7.4.10 + tldts: 7.4.11 optional: true tr46@6.0.0: