Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
55 changes: 53 additions & 2 deletions packages/docs/src/pages/learn/FileSystemRouting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -132,7 +132,58 @@ 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 — 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

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)
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 (
<article>
Post generated for: {params.slug}
<LiveSlug route={route} />
</article>
);
}
```

```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 <p>Now viewing: {params.slug}</p>;
}
```

The `FsRouteComponentProps<Params>` 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.

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)

Expand Down
2 changes: 1 addition & 1 deletion packages/example-fs-routing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
2 changes: 1 addition & 1 deletion packages/static/e2e/fixture-fs-routing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"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 (and its route object), so it stays correct across soft
// client-side navigation.
export default function ClientLangPage({
params,
route,
}: FsRouteComponentProps<{ lang: string }>) {
const liveParams = useRouteParams(route);
return (
<div>
<p data-testid="page-id">lang-client</p>
<p data-testid="client-page-lang">{params.lang}</p>
<p data-testid="client-page-hook-lang">{liveParams.lang}</p>
<a href="/en/client" data-testid="link-en-client">
English client page
</a>{" "}
<a href="/ja/client" data-testid="link-ja-client">
Japanese client page
</a>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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";
16 changes: 16 additions & 0 deletions packages/static/e2e/fixture-fs-routing/src/pages/[lang]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section>
<p data-testid="lang-layout-pathname">{location.pathname}</p>
<p data-testid="lang-layout-lang">{params.lang}</p>
<Outlet />
</section>
);
}
Original file line number Diff line number Diff line change
@@ -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 <p data-testid="live-lang">{params.lang}</p>;
}
30 changes: 30 additions & 0 deletions packages/static/e2e/fixture-fs-routing/src/pages/[lang]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<p data-testid="page-id">lang</p>
<p data-testid="lang-page-lang">{params.lang}</p>
<LiveLang route={route} />
<a href="/en" data-testid="link-en">
English
</a>{" "}
<a href="/ja" data-testid="link-ja">
Japanese
</a>{" "}
<a href="/en/client" data-testid="link-en-client">
English client page
</a>
</div>
);
}
20 changes: 20 additions & 0 deletions packages/static/e2e/tests-dev/fs-routing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
67 changes: 67 additions & 0 deletions packages/static/e2e/tests/fs-routing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) => {
Expand All @@ -96,3 +111,55 @@ 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 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 ({
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([]);
});
});
4 changes: 2 additions & 2 deletions packages/static/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"author": "uhyo <uhyo@uhy.ooo>",
"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",
Expand All @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions packages/static/src/fs-routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
export { nextRoutes, type NextRoutesOptions } from "./nextAdapter";
export type {
FsRoutesAdapter,
FsRouteComponentProps,
FsRouteFile,
FsRouteModule,
FsRouteObject,
FsRouteTreeNode,
FsRootComponent,
MaybePromise,
Expand Down
Loading