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
10 changes: 7 additions & 3 deletions packages/docs/src/pages/learn/FileSystemRouting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<section>
<p data-testid="info-layout-lang">{params.lang}</p>
<Outlet />
</section>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<p data-testid="page-id">info</p>
<p data-testid="info-page-lang">{params.lang}</p>
<a href="/en/info" data-testid="link-en-info">
English info
</a>{" "}
<a href="/ja/info" data-testid="link-ja-info">
Japanese info
</a>{" "}
<a href="/en" data-testid="link-en-home">
English home
</a>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export default function LangPage({
</a>{" "}
<a href="/en/client" data-testid="link-en-client">
English client page
</a>{" "}
<a href="/en/info" data-testid="link-en-info">
English info
</a>
</div>
);
Expand Down
21 changes: 21 additions & 0 deletions packages/static/e2e/tests-dev/fs-routing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
43 changes: 41 additions & 2 deletions packages/static/e2e/tests/fs-routing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 ({
Expand Down
50 changes: 50 additions & 0 deletions packages/static/src/fs-routes/entries.tsx
Original file line number Diff line number Diff line change
@@ -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);
}
3 changes: 2 additions & 1 deletion packages/static/src/fs-routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
140 changes: 127 additions & 13 deletions packages/static/src/fs-routes/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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<FsRouteComponentProps>;
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<FsRouteComponentProps>,
name,
id,
});
return id;
},
RouteSlot: FakeSlot,
};
return { host, registered };
}

interface DefinitionLike {
id?: string;
path?: string;
Expand Down Expand Up @@ -48,19 +80,30 @@ function collectDefinitions(

async function entriesFor(
modules: Record<string, FsRouteModule>,
host: FsRoutesRuntimeHost = fakeHost().host,
): Promise<EntryDefinition[]> {
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);
}
return entries;
}

function slotOf(definition: DefinitionLike): FsRouteSlotProps {
expect(isValidElement(definition.component)).toBe(true);
const element = definition.component as ReactElement<FsRouteSlotProps>;
expect(element.type).toBe(FakeSlot);
return element.props;
}

describe("createFsRoutesEntries route definitions", () => {
const clientLayout = clientReference();
const modules: Record<string, FsRouteModule> = {
Expand All @@ -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<FsRouteComponentProps>)
.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<FsRouteComponentProps>;
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 () => {
Expand Down
Loading