From 5ee7cead4a39646c5b7381a911fda4bed4578c50 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 11:58:39 +0000 Subject: [PATCH 1/3] feat: add mpa mode to file-system routing Add an optional `mode` option to `fsRoutes` (and `createFsRoutesEntries`). The default `"spa"` keeps the current behavior: pages render with FUNSTACK Router's ``, links are followed by soft client-side navigation, and Server Component output is pre-rendered as RSC chunks fetched on navigation. The new `"mpa"` mode renders pages with `` (FUNSTACK Router 1.6), which never intercepts navigations, so every link loads the destination's own HTML. Each page's route definitions cover only its own route chain with Server Component output inlined directly, and no navigation chunks are registered or emitted. Includes unit tests, an e2e fixture with build and dev-server specs, and documentation for the new option. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014ERdZEFjZXcbrh8pQttyc4 --- .../docs/src/pages/api/FunstackStatic.mdx | 3 +- .../src/pages/learn/FileSystemRouting.mdx | 26 ++- .../e2e/fixture-fs-routing-mpa/package.json | 15 ++ .../src/nav-counter.tsx | 17 ++ .../src/pages/[lang]/client/_page.tsx | 22 ++ .../src/pages/[lang]/client/page.tsx | 6 + .../src/pages/[lang]/layout.tsx | 15 ++ .../src/pages/[lang]/page.tsx | 25 +++ .../src/pages/about/page.tsx | 8 + .../src/pages/blog/[slug]/live-slug.tsx | 14 ++ .../src/pages/blog/[slug]/page.tsx | 24 +++ .../src/pages/blog/featured/page.tsx | 8 + .../src/pages/dashboard/layout.tsx | 10 + .../src/pages/dashboard/page.tsx | 8 + .../src/pages/dashboard/settings/page.tsx | 8 + .../fixture-fs-routing-mpa/src/pages/page.tsx | 8 + .../e2e/fixture-fs-routing-mpa/src/root.tsx | 23 ++ .../e2e/fixture-fs-routing-mpa/vite.config.ts | 20 ++ packages/static/e2e/playwright-dev.config.ts | 15 ++ packages/static/e2e/playwright.config.ts | 15 ++ .../e2e/tests-dev/fs-routing-mpa.spec.ts | 88 ++++++++ .../static/e2e/tests/fs-routing-mpa.spec.ts | 203 ++++++++++++++++++ packages/static/src/fs-routes/index.ts | 2 +- packages/static/src/fs-routes/runtime.test.ts | 143 +++++++++++- packages/static/src/fs-routes/runtime.tsx | 104 ++++++++- packages/static/src/plugin/index.test.ts | 33 +++ packages/static/src/plugin/index.ts | 31 ++- pnpm-lock.yaml | 27 +++ pnpm-workspace.yaml | 1 + 29 files changed, 909 insertions(+), 13 deletions(-) create mode 100644 packages/static/e2e/fixture-fs-routing-mpa/package.json create mode 100644 packages/static/e2e/fixture-fs-routing-mpa/src/nav-counter.tsx create mode 100644 packages/static/e2e/fixture-fs-routing-mpa/src/pages/[lang]/client/_page.tsx create mode 100644 packages/static/e2e/fixture-fs-routing-mpa/src/pages/[lang]/client/page.tsx create mode 100644 packages/static/e2e/fixture-fs-routing-mpa/src/pages/[lang]/layout.tsx create mode 100644 packages/static/e2e/fixture-fs-routing-mpa/src/pages/[lang]/page.tsx create mode 100644 packages/static/e2e/fixture-fs-routing-mpa/src/pages/about/page.tsx create mode 100644 packages/static/e2e/fixture-fs-routing-mpa/src/pages/blog/[slug]/live-slug.tsx create mode 100644 packages/static/e2e/fixture-fs-routing-mpa/src/pages/blog/[slug]/page.tsx create mode 100644 packages/static/e2e/fixture-fs-routing-mpa/src/pages/blog/featured/page.tsx create mode 100644 packages/static/e2e/fixture-fs-routing-mpa/src/pages/dashboard/layout.tsx create mode 100644 packages/static/e2e/fixture-fs-routing-mpa/src/pages/dashboard/page.tsx create mode 100644 packages/static/e2e/fixture-fs-routing-mpa/src/pages/dashboard/settings/page.tsx create mode 100644 packages/static/e2e/fixture-fs-routing-mpa/src/pages/page.tsx create mode 100644 packages/static/e2e/fixture-fs-routing-mpa/src/root.tsx create mode 100644 packages/static/e2e/fixture-fs-routing-mpa/vite.config.ts create mode 100644 packages/static/e2e/tests-dev/fs-routing-mpa.spec.ts create mode 100644 packages/static/e2e/tests/fs-routing-mpa.spec.ts diff --git a/packages/docs/src/pages/api/FunstackStatic.mdx b/packages/docs/src/pages/api/FunstackStatic.mdx index 4c3852e..b510830 100644 --- a/packages/docs/src/pages/api/FunstackStatic.mdx +++ b/packages/docs/src/pages/api/FunstackStatic.mdx @@ -209,11 +209,12 @@ funstackStatic({ }); ``` -`FsRoutesConfig` fields (all required — you must be explicit about each): +`FsRoutesConfig` fields: - **`dir`** (required) — directory scanned for route files, relative to the Vite root. Commonly `"./src/pages"`. - **`root`** (required) — path to the root (HTML shell) component module. - **`adapter`** (required) — a module that `export default`s an `FsRoutesAdapter`, given as a bare module specifier or a path relative to the Vite root. Use the bundled `@funstack/static/fs-routes/next-adapter` for the built-in Next.js-like convention, or point it at your own module for a custom one. +- **`mode`** (optional, default `"spa"`) — `"spa"` renders pages with FUNSTACK Router's ``, so links within the site are followed by soft client-side navigation (with the Server Component output of every page pre-rendered as RSC chunks fetched on navigation). `"mpa"` renders pages with ``, which never intercepts navigations: every link loads the destination's own HTML, and only each page's own RSC payload is emitted. See [SPA or MPA](/learn/file-system-routing#spa-or-mpa). Enabling [`ssr`](#ssr-optional) alongside `fsRoutes` is optional but recommended for SEO and faster initial load. diff --git a/packages/docs/src/pages/learn/FileSystemRouting.mdx b/packages/docs/src/pages/learn/FileSystemRouting.mdx index 9e8010a..14ba878 100644 --- a/packages/docs/src/pages/learn/FileSystemRouting.mdx +++ b/packages/docs/src/pages/learn/FileSystemRouting.mdx @@ -39,12 +39,14 @@ export default defineConfig({ }); ``` -All `fsRoutes` fields are required, so each choice is explicit in your config: +These `fsRoutes` fields are required, so each choice is explicit in your config: - `dir` — the directory scanned for route files (commonly `./src/pages`). - `root` — the HTML shell component (`…{children}`). - `adapter` — a module that `export default`s the adapter defining the directory / file-name convention. The bundled `@funstack/static/fs-routes/next-adapter` provides the built-in Next.js-like convention with default options; see [Custom Conventions](#custom-conventions-adapters) to configure it or write your own. +Optionally, `mode` chooses between a single-page application with soft client-side navigation (`"spa"`, the default) and a multi-page application where every link loads a new document (`"mpa"`). See [SPA or MPA](#spa-or-mpa). + `fsRoutes` is mutually exclusive with the `root` + `app` (single-entry) and `entries` (multiple entries) modes. > **Consider enabling [`ssr`](/api/funstack-static).** Pages are server components rendered through FUNSTACK Router. `ssr` is optional and works the same in dev (`vite dev`), production builds, and preview either way, but enabling it (`ssr: true`) is recommended for SEO and faster initial load. @@ -140,8 +142,30 @@ export function generateStaticParams() { export { default } from "./_page"; // _page.tsx is marked "use client" ``` +## SPA or MPA + +Every route becomes its own HTML file either way, so any page can be loaded directly, and the site works on any static host. The `mode` option decides what happens when the visitor follows a link _within_ the site: + +```typescript +funstackStatic({ + fsRoutes: { + dir: "./src/pages", + root: "./src/root.tsx", + adapter: "@funstack/static/fs-routes/next-adapter", + mode: "mpa", // or "spa" (the default) + }, +}); +``` + +- **`"spa"`** (default) — pages are rendered with FUNSTACK Router's [``](https://router.funstack.work/api/components). Clicking a link to another page is intercepted and handled as a **soft client-side navigation**: the document stays, layouts shared with the destination keep their state, and only the changed part of the page is rendered. The build pre-renders the Server Component output of every page as static RSC chunks that the client fetches on navigation (see [Params on Client-Side Navigation](#params-on-client-side-navigation) for the details). +- **`"mpa"`** — pages are rendered with FUNSTACK Router's [``](https://router.funstack.work/api/components), which never intercepts navigations. Clicking a link performs a regular **full page load** of the destination's own HTML, like a classic multi-page site; nothing is fetched on navigation, and the build emits only each page's own RSC payload. Pick this when you do not want client-side routing — for example for a content site where each page should stand alone, or to keep the behavior of plain HTML links. + +Pages and layouts are written the same way in both modes: a Client Component always receives the params of the URL being shown, and a Server Component the params it was generated with (which in MPA mode are always the same, since the document never outlives its URL). FUNSTACK Router's hooks that read the location (`useLocation`, `useSearchParams`, `useRouteParams`, …) work in both modes, but in MPA mode features that depend on intercepting navigations are inert — `useBlocker` never blocks, and a setter that changes the URL (such as the one from `useSearchParams`) performs a full page navigation. + ## Params on Client-Side Navigation +> This section describes the default `"spa"` [mode](#spa-or-mpa). In `"mpa"` mode there is no client-side navigation: every page is loaded directly, so `params` always match the URL. + 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. diff --git a/packages/static/e2e/fixture-fs-routing-mpa/package.json b/packages/static/e2e/fixture-fs-routing-mpa/package.json new file mode 100644 index 0000000..f0d4330 --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing-mpa/package.json @@ -0,0 +1,15 @@ +{ + "name": "e2e-fixture-fs-routing-mpa", + "private": true, + "type": "module", + "devDependencies": { + "@funstack/router": "^1.6.0", + "@funstack/static": "workspace:*", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "vite": "catalog:" + } +} diff --git a/packages/static/e2e/fixture-fs-routing-mpa/src/nav-counter.tsx b/packages/static/e2e/fixture-fs-routing-mpa/src/nav-counter.tsx new file mode 100644 index 0000000..0188a62 --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing-mpa/src/nav-counter.tsx @@ -0,0 +1,17 @@ +"use client"; +import { useState } from "react"; + +// Client-side state in the shell. It survives soft client-side navigation +// but is reset by a full page load, so it tells the two apart. +export function NavCounter() { + const [count, setCount] = useState(0); + return ( + + ); +} diff --git a/packages/static/e2e/fixture-fs-routing-mpa/src/pages/[lang]/client/_page.tsx b/packages/static/e2e/fixture-fs-routing-mpa/src/pages/[lang]/client/_page.tsx new file mode 100644 index 0000000..e84ea98 --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing-mpa/src/pages/[lang]/client/_page.tsx @@ -0,0 +1,22 @@ +"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 params of the +// matched URL and its route object. +export default function ClientLangPage({ + params, + route, +}: FsRouteComponentProps<{ lang: string }>) { + const liveParams = useRouteParams(route); + return ( +
+

lang-client

+

{params.lang}

+

{liveParams.lang}

+ + Japanese client page + +
+ ); +} diff --git a/packages/static/e2e/fixture-fs-routing-mpa/src/pages/[lang]/client/page.tsx b/packages/static/e2e/fixture-fs-routing-mpa/src/pages/[lang]/client/page.tsx new file mode 100644 index 0000000..770f500 --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing-mpa/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-mpa/src/pages/[lang]/layout.tsx b/packages/static/e2e/fixture-fs-routing-mpa/src/pages/[lang]/layout.tsx new file mode 100644 index 0000000..7b70266 --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing-mpa/src/pages/[lang]/layout.tsx @@ -0,0 +1,15 @@ +"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 matched URL. +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-mpa/src/pages/[lang]/page.tsx b/packages/static/e2e/fixture-fs-routing-mpa/src/pages/[lang]/page.tsx new file mode 100644 index 0000000..aec9a8b --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing-mpa/src/pages/[lang]/page.tsx @@ -0,0 +1,25 @@ +import type { FsRouteComponentProps } from "@funstack/static/fs-routes"; + +export function generateStaticParams() { + return [{ lang: "en" }, { lang: "ja" }]; +} + +export default function LangPage({ + params, +}: FsRouteComponentProps<{ lang: string }>) { + return ( +
+

lang

+

{params.lang}

+ + English + {" "} + + Japanese + {" "} + + English client page + +
+ ); +} diff --git a/packages/static/e2e/fixture-fs-routing-mpa/src/pages/about/page.tsx b/packages/static/e2e/fixture-fs-routing-mpa/src/pages/about/page.tsx new file mode 100644 index 0000000..9c3177c --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing-mpa/src/pages/about/page.tsx @@ -0,0 +1,8 @@ +export default function About() { + return ( +
+

About Page

+

about

+
+ ); +} diff --git a/packages/static/e2e/fixture-fs-routing-mpa/src/pages/blog/[slug]/live-slug.tsx b/packages/static/e2e/fixture-fs-routing-mpa/src/pages/blog/[slug]/live-slug.tsx new file mode 100644 index 0000000..0672f48 --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing-mpa/src/pages/blog/[slug]/live-slug.tsx @@ -0,0 +1,14 @@ +"use client"; +import { useRouteParams } from "@funstack/router"; +import type { FsRouteObject } from "@funstack/static/fs-routes"; + +// A Client Component reading the params of the current URL through the route +// object; in an MPA this always equals the params the page was built with. +export function LiveSlug({ + route, +}: { + route: FsRouteObject<{ slug: string }>; +}) { + const params = useRouteParams(route); + return

{params.slug}

; +} diff --git a/packages/static/e2e/fixture-fs-routing-mpa/src/pages/blog/[slug]/page.tsx b/packages/static/e2e/fixture-fs-routing-mpa/src/pages/blog/[slug]/page.tsx new file mode 100644 index 0000000..e565bd2 --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing-mpa/src/pages/blog/[slug]/page.tsx @@ -0,0 +1,24 @@ +import type { FsRouteComponentProps } from "@funstack/static/fs-routes"; +import { LiveSlug } from "./live-slug"; + +export function generateStaticParams() { + return [{ slug: "hello" }, { slug: "world" }]; +} + +// A Server Component page: rendered at build time with the concrete params. +export default function BlogPost({ + params, + route, +}: FsRouteComponentProps<{ slug: string }>) { + return ( +
+

Blog Post

+

blog-post

+

{params.slug}

+ + + World post + +
+ ); +} diff --git a/packages/static/e2e/fixture-fs-routing-mpa/src/pages/blog/featured/page.tsx b/packages/static/e2e/fixture-fs-routing-mpa/src/pages/blog/featured/page.tsx new file mode 100644 index 0000000..b8d0fd8 --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing-mpa/src/pages/blog/featured/page.tsx @@ -0,0 +1,8 @@ +export default function FeaturedPosts() { + return ( +
+

Featured Posts

+

blog-featured

+
+ ); +} diff --git a/packages/static/e2e/fixture-fs-routing-mpa/src/pages/dashboard/layout.tsx b/packages/static/e2e/fixture-fs-routing-mpa/src/pages/dashboard/layout.tsx new file mode 100644 index 0000000..12097fd --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing-mpa/src/pages/dashboard/layout.tsx @@ -0,0 +1,10 @@ +import { Outlet } from "@funstack/router"; + +export default function DashboardLayout() { + return ( +
+

dashboard-layout

+ +
+ ); +} diff --git a/packages/static/e2e/fixture-fs-routing-mpa/src/pages/dashboard/page.tsx b/packages/static/e2e/fixture-fs-routing-mpa/src/pages/dashboard/page.tsx new file mode 100644 index 0000000..73f4529 --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing-mpa/src/pages/dashboard/page.tsx @@ -0,0 +1,8 @@ +export default function Dashboard() { + return ( +
+

Dashboard

+

dashboard

+
+ ); +} diff --git a/packages/static/e2e/fixture-fs-routing-mpa/src/pages/dashboard/settings/page.tsx b/packages/static/e2e/fixture-fs-routing-mpa/src/pages/dashboard/settings/page.tsx new file mode 100644 index 0000000..e4ebe68 --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing-mpa/src/pages/dashboard/settings/page.tsx @@ -0,0 +1,8 @@ +export default function DashboardSettings() { + return ( +
+

Dashboard Settings

+

dashboard-settings

+
+ ); +} diff --git a/packages/static/e2e/fixture-fs-routing-mpa/src/pages/page.tsx b/packages/static/e2e/fixture-fs-routing-mpa/src/pages/page.tsx new file mode 100644 index 0000000..aa8b635 --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing-mpa/src/pages/page.tsx @@ -0,0 +1,8 @@ +export default function Home() { + return ( +
+

Home Page

+

home

+
+ ); +} diff --git a/packages/static/e2e/fixture-fs-routing-mpa/src/root.tsx b/packages/static/e2e/fixture-fs-routing-mpa/src/root.tsx new file mode 100644 index 0000000..8dacbbf --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing-mpa/src/root.tsx @@ -0,0 +1,23 @@ +import { NavCounter } from "./nav-counter"; + +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + + + + FS Routing MPA E2E Fixture + + + +
{children}
+ + + ); +} diff --git a/packages/static/e2e/fixture-fs-routing-mpa/vite.config.ts b/packages/static/e2e/fixture-fs-routing-mpa/vite.config.ts new file mode 100644 index 0000000..50076c9 --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing-mpa/vite.config.ts @@ -0,0 +1,20 @@ +import funstackStatic from "@funstack/static"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + funstackStatic({ + ssr: true, + fsRoutes: { + dir: "./src/pages", + root: "./src/root.tsx", + adapter: "@funstack/static/fs-routes/next-adapter", + // Multi-page application: links load the destination's own HTML + // instead of soft client-side navigation. + mode: "mpa", + }, + }), + react(), + ], +}); diff --git a/packages/static/e2e/playwright-dev.config.ts b/packages/static/e2e/playwright-dev.config.ts index cc0c6ac..3865eca 100644 --- a/packages/static/e2e/playwright-dev.config.ts +++ b/packages/static/e2e/playwright-dev.config.ts @@ -45,6 +45,14 @@ export default defineConfig({ }, testMatch: /\/fs-routing\.spec\.ts$/, }, + { + name: "fs-routing-mpa-dev", + use: { + ...devices["Desktop Chrome"], + baseURL: "http://localhost:4182", + }, + testMatch: /\/fs-routing-mpa\.spec\.ts$/, + }, ], webServer: [ @@ -74,5 +82,12 @@ export default defineConfig({ reuseExistingServer: !process.env.CI, timeout: 120000, }, + { + command: + "cd fixture-fs-routing-mpa && pnpm vite dev --port 4182 --strictPort", + url: "http://localhost:4182/@vite/client", + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, ], }); diff --git a/packages/static/e2e/playwright.config.ts b/packages/static/e2e/playwright.config.ts index 9dd528d..47c86f8 100644 --- a/packages/static/e2e/playwright.config.ts +++ b/packages/static/e2e/playwright.config.ts @@ -45,6 +45,14 @@ export default defineConfig({ }, testMatch: /\/fs-routing\.spec\.ts$/, }, + { + name: "fs-routing-mpa", + use: { + ...devices["Desktop Chrome"], + baseURL: "http://localhost:4181", + }, + testMatch: /\/fs-routing-mpa\.spec\.ts$/, + }, { // Runs `vite build` itself; needs no web server name: "render-error", @@ -81,5 +89,12 @@ export default defineConfig({ reuseExistingServer: !process.env.CI, timeout: 120000, }, + { + command: + "cd fixture-fs-routing-mpa && pnpm vite build && pnpm dlx serve -p 4181 dist/public", + url: "http://localhost:4181", + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, ], }); diff --git a/packages/static/e2e/tests-dev/fs-routing-mpa.spec.ts b/packages/static/e2e/tests-dev/fs-routing-mpa.spec.ts new file mode 100644 index 0000000..a2d1651 --- /dev/null +++ b/packages/static/e2e/tests-dev/fs-routing-mpa.spec.ts @@ -0,0 +1,88 @@ +import { expect, test, type Page } from "@playwright/test"; + +async function markDocument(page: Page): Promise { + await page.evaluate(() => { + (window as unknown as { __document_mark: boolean }).__document_mark = true; + }); +} + +async function isSameDocument(page: Page): Promise { + return page.evaluate( + () => + (window as unknown as { __document_mark?: boolean }).__document_mark === + true, + ); +} + +test.describe("File-system routing (mpa, dev server)", () => { + test("renders the index page", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("page-id")).toHaveText("home"); + }); + + test("renders a dynamic route with params", async ({ page }) => { + await page.goto("/blog/hello"); + await expect(page.getByTestId("slug")).toHaveText("hello"); + await expect(page.getByTestId("live-slug")).toHaveText("hello"); + }); + + test("renders a static page instead of a dynamic sibling route", async ({ + page, + }) => { + await page.goto("/blog/featured"); + await expect(page.getByTestId("page-id")).toHaveText("blog-featured"); + }); + + test("wraps nested pages in their layout", async ({ page }) => { + await page.goto("/dashboard/settings"); + await expect(page.getByTestId("dashboard-layout")).toHaveText( + "dashboard-layout", + ); + await expect(page.getByTestId("page-id")).toHaveText("dashboard-settings"); + }); + + test("a Client Component layout receives the matched params", async ({ + page, + }) => { + await page.goto("/ja"); + await expect(page.getByTestId("lang-layout-lang")).toHaveText("ja"); + await expect(page.getByTestId("lang-page-lang")).toHaveText("ja"); + }); + + test("a link click loads the destination as a new document", async ({ + page, + }) => { + await page.goto("/"); + await expect(page.getByTestId("page-id")).toHaveText("home"); + await markDocument(page); + + await page.getByRole("link", { name: "About" }).click(); + await expect(page.getByTestId("page-id")).toHaveText("about"); + expect(await isSameDocument(page)).toBe(false); + }); + + test("navigating between pages of a dynamic route loads a new document", async ({ + page, + }) => { + await page.goto("/ja"); + await expect(page.getByTestId("lang-page-lang")).toHaveText("ja"); + await markDocument(page); + + await page.getByTestId("link-en").click(); + await expect(page.getByTestId("lang-layout-lang")).toHaveText("en"); + await expect(page.getByTestId("lang-page-lang")).toHaveText("en"); + expect(await isSameDocument(page)).toBe(false); + }); + + test("no JavaScript errors while navigating", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (error) => { + errors.push(error.message); + }); + await page.goto("/"); + await page.waitForLoadState("networkidle"); + await page.getByRole("link", { name: "Dashboard" }).click(); + await expect(page.getByTestId("page-id")).toHaveText("dashboard"); + expect(errors).toEqual([]); + }); +}); diff --git a/packages/static/e2e/tests/fs-routing-mpa.spec.ts b/packages/static/e2e/tests/fs-routing-mpa.spec.ts new file mode 100644 index 0000000..39ab125 --- /dev/null +++ b/packages/static/e2e/tests/fs-routing-mpa.spec.ts @@ -0,0 +1,203 @@ +import { expect, test, type Page } from "@playwright/test"; + +/** + * Marks the current document, so that a later check can tell whether a + * navigation replaced the document (the mark is gone) or was handled as a + * soft client-side navigation within it (the mark survives). + */ +async function markDocument(page: Page): Promise { + await page.evaluate(() => { + (window as unknown as { __document_mark: boolean }).__document_mark = true; + }); +} + +async function isSameDocument(page: Page): Promise { + return page.evaluate( + () => + (window as unknown as { __document_mark?: boolean }).__document_mark === + true, + ); +} + +test.describe("File-system routing (mpa) build output", () => { + test("generates an HTML file per route", async ({ request }) => { + for (const path of [ + "/", + "/about", + "/blog/featured", + "/blog/hello", + "/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); + const html = await response.text(); + expect(html).toContain(""); + expect(html).toContain("funstack__/fun__rsc-payload/"); + } + }); + + test("server-renders each page's own content", async ({ request }) => { + const about = await (await request.get("/about")).text(); + expect(about).toContain(">about<"); + expect(about).not.toContain(">home<"); + + const post = await (await request.get("/blog/world")).text(); + expect(post).toContain(">blog-post<"); + expect(post).toContain('data-testid="slug">world<'); + }); +}); + +test.describe("File-system routing (mpa) rendering", () => { + test("renders the index page", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("page-id")).toHaveText("home"); + }); + + test("renders a static nested page", async ({ page }) => { + await page.goto("/about"); + await expect(page.getByTestId("page-id")).toHaveText("about"); + }); + + test("renders a static page instead of a dynamic sibling route", async ({ + page, + }) => { + await page.goto("/blog/featured"); + await expect(page.getByTestId("page-id")).toHaveText("blog-featured"); + }); + + test("statically generates dynamic routes with params", async ({ page }) => { + await page.goto("/blog/hello"); + await expect(page.getByTestId("page-id")).toHaveText("blog-post"); + await expect(page.getByTestId("slug")).toHaveText("hello"); + await expect(page.getByTestId("live-slug")).toHaveText("hello"); + + await page.goto("/blog/world"); + await expect(page.getByTestId("slug")).toHaveText("world"); + await expect(page.getByTestId("live-slug")).toHaveText("world"); + }); + + test("wraps nested pages in their layout", async ({ page }) => { + await page.goto("/dashboard"); + await expect(page.getByTestId("dashboard-layout")).toHaveText( + "dashboard-layout", + ); + await expect(page.getByTestId("page-id")).toHaveText("dashboard"); + + await page.goto("/dashboard/settings"); + await expect(page.getByTestId("dashboard-layout")).toHaveText( + "dashboard-layout", + ); + await expect(page.getByTestId("page-id")).toHaveText("dashboard-settings"); + }); + + test("a Client Component layout and page receive the matched params", async ({ + page, + }) => { + for (const lang of ["en", "ja"]) { + await page.goto(`/${lang}`); + await expect(page.getByTestId("lang-layout-pathname")).toHaveText( + `/${lang}`, + ); + await expect(page.getByTestId("lang-layout-lang")).toHaveText(lang); + await expect(page.getByTestId("lang-page-lang")).toHaveText(lang); + + await page.goto(`/${lang}/client`); + await expect(page.getByTestId("client-page-lang")).toHaveText(lang); + await expect(page.getByTestId("client-page-hook-lang")).toHaveText(lang); + } + }); + + test("hydrates without errors", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (error) => { + errors.push(error.message); + }); + page.on("console", (message) => { + if (message.type() === "error") { + errors.push(message.text()); + } + }); + for (const path of ["/", "/blog/hello", "/dashboard/settings", "/ja"]) { + await page.goto(path); + await page.waitForLoadState("networkidle"); + // Client-side state proves the page is interactive (hydrated). + await page.getByTestId("nav-counter").click(); + await expect(page.getByTestId("nav-counter")).toHaveText("1"); + } + expect(errors).toEqual([]); + }); +}); + +test.describe("File-system routing (mpa) navigation", () => { + test("a link click loads the destination as a new document", async ({ + page, + }) => { + await page.goto("/"); + await expect(page.getByTestId("page-id")).toHaveText("home"); + await page.getByTestId("nav-counter").click(); + await expect(page.getByTestId("nav-counter")).toHaveText("1"); + await markDocument(page); + + await page.getByRole("link", { name: "About" }).click(); + await expect(page.getByTestId("page-id")).toHaveText("about"); + expect(page.url()).toMatch(/\/about$/); + // No soft navigation: the document was replaced and shell state reset. + expect(await isSameDocument(page)).toBe(false); + await expect(page.getByTestId("nav-counter")).toHaveText("0"); + }); + + test("navigating between pages of a dynamic route loads each page's own document", async ({ + page, + }) => { + await page.goto("/blog/hello"); + await expect(page.getByTestId("slug")).toHaveText("hello"); + await markDocument(page); + + await page.getByTestId("link-world").click(); + await expect(page.getByTestId("slug")).toHaveText("world"); + await expect(page.getByTestId("live-slug")).toHaveText("world"); + expect(await isSameDocument(page)).toBe(false); + + await page.goBack(); + await expect(page.getByTestId("slug")).toHaveText("hello"); + await expect(page.getByTestId("live-slug")).toHaveText("hello"); + }); + + test("navigating under a Client Component layout loads a new document", async ({ + page, + }) => { + await page.goto("/ja"); + await expect(page.getByTestId("lang-layout-lang")).toHaveText("ja"); + await markDocument(page); + + await page.getByTestId("link-en").click(); + await expect(page.getByTestId("lang-layout-lang")).toHaveText("en"); + await expect(page.getByTestId("lang-page-lang")).toHaveText("en"); + expect(await isSameDocument(page)).toBe(false); + + await page.getByTestId("link-en-client").click(); + await expect(page.getByTestId("client-page-hook-lang")).toHaveText("en"); + await page.getByTestId("link-ja-client").click(); + await expect(page.getByTestId("client-page-hook-lang")).toHaveText("ja"); + }); + + test("no JavaScript errors while navigating", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (error) => { + errors.push(error.message); + }); + await page.goto("/"); + await page.waitForLoadState("networkidle"); + await page.getByRole("link", { name: "Dashboard" }).click(); + await expect(page.getByTestId("page-id")).toHaveText("dashboard"); + await page.getByRole("link", { name: "Hello post" }).click(); + await expect(page.getByTestId("live-slug")).toHaveText("hello"); + expect(errors).toEqual([]); + }); +}); diff --git a/packages/static/src/fs-routes/index.ts b/packages/static/src/fs-routes/index.ts index 1ccb574..495826f 100644 --- a/packages/static/src/fs-routes/index.ts +++ b/packages/static/src/fs-routes/index.ts @@ -19,4 +19,4 @@ export type { } from "./types"; export { collectStaticPaths, urlPathToFilePath, type StaticPage } from "./tree"; export { createFsRoutesEntries } from "./entries"; -export type { CreateFsRoutesOptions } from "./runtime"; +export type { CreateFsRoutesOptions, FsRoutesMode } from "./runtime"; diff --git a/packages/static/src/fs-routes/runtime.test.ts b/packages/static/src/fs-routes/runtime.test.ts index ef3f476..27f926b 100644 --- a/packages/static/src/fs-routes/runtime.test.ts +++ b/packages/static/src/fs-routes/runtime.test.ts @@ -1,7 +1,9 @@ import { isValidElement, type ReactElement } from "react"; +import { RouteDisplay, Router } from "@funstack/router"; import { describe, expect, it, vi } from "vitest"; import { createFsRoutesEntriesWithHost, + type CreateFsRoutesOptions, type FsRoutesRuntimeHost, } from "./runtime"; import type { EntryDefinition } from "../entryDefinition"; @@ -71,13 +73,22 @@ interface DefinitionLike { /** * Renders an entry's `FsRoutesApp` element (a plain function component) to - * obtain the Router element and returns its route definitions. + * obtain the Router (or RouteDisplay) element. */ -function routesOfEntry(entry: EntryDefinition): DefinitionLike[] { +function routerOfEntry( + entry: EntryDefinition, +): React.ReactElement<{ routes: DefinitionLike[] }> { const app = entry.app as React.ReactElement & { type: (props: object) => React.ReactElement<{ routes: DefinitionLike[] }>; }; - return app.type(app.props).props.routes; + return app.type(app.props); +} + +/** + * Returns the route definitions of an entry's Router (or RouteDisplay). + */ +function routesOfEntry(entry: EntryDefinition): DefinitionLike[] { + return routerOfEntry(entry).props.routes; } function collectDefinitions( @@ -96,12 +107,14 @@ function collectDefinitions( async function entriesFor( modules: Record, host: FsRoutesRuntimeHost = fakeHost().host, + options: Partial = {}, ): Promise { const getEntries = createFsRoutesEntriesWithHost( { modules, base: "./pages", root: Root, + ...options, }, host, ); @@ -130,6 +143,12 @@ describe("createFsRoutesEntries route definitions", () => { "./pages/about/page.tsx": { default: () => null }, }; + it("renders pages with Router in the default (spa) mode", async () => { + const entries = await entriesFor(modules); + const router = routerOfEntry(entries[0]!); + expect(router.type).toBe(Router); + }); + 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")!); @@ -256,6 +275,124 @@ describe("createFsRoutesEntries route definitions", () => { }); }); +describe("createFsRoutesEntries in mpa mode", () => { + const clientLayout = clientReference(); + const clientPage = clientReference(); + const serverLayout = () => null; + const modules: Record = { + "./pages/[lang]/layout.tsx": { default: clientLayout }, + "./pages/[lang]/page.tsx": { + default: () => null, + generateStaticParams: () => [{ lang: "en" }, { lang: "ja" }], + }, + "./pages/[lang]/client/page.tsx": { + default: clientPage, + generateStaticParams: () => [{ lang: "en" }], + }, + "./pages/docs/layout.tsx": { default: serverLayout }, + "./pages/docs/[slug]/page.tsx": { + default: () => null, + generateStaticParams: () => [{ slug: "a" }, { slug: "b" }], + }, + "./pages/about/page.tsx": { default: () => null }, + }; + + async function mpaEntriesFor( + host: FsRoutesRuntimeHost = fakeHost().host, + ): Promise { + return entriesFor(modules, host, { mode: "mpa" }); + } + + function entryAt(entries: EntryDefinition[], path: string): EntryDefinition { + const entry = entries.find((e) => e.path === path); + expect(entry, `entry ${path}`).toBeDefined(); + return entry!; + } + + it("generates the same pages as spa mode", async () => { + const spa = await entriesFor(modules); + const mpa = await mpaEntriesFor(); + expect(mpa.map((e) => e.path)).toEqual(spa.map((e) => e.path)); + }); + + it("renders pages with RouteDisplay matching the page's own URL", async () => { + const entries = await mpaEntriesFor(); + const router = routerOfEntry(entryAt(entries, "docs/a.html")); + expect(router.type).toBe(RouteDisplay); + expect(router.props).toMatchObject({ ssr: { path: "/docs/a" } }); + expect(router.props).not.toHaveProperty("fallback"); + }); + + it("defines only the page's own route chain, nested root-first", async () => { + const entries = await mpaEntriesFor(); + const routes = routesOfEntry(entryAt(entries, "docs/b.html")); + expect(routes).toHaveLength(1); + const layout = routes[0]!; + expect(layout.path).toBe("/docs"); + expect(layout.children).toHaveLength(1); + const page = layout.children![0]!; + expect(page.path).toBe("/:slug"); + expect(page.children).toBeUndefined(); + }); + + it("inlines a Server Component's build-time output with its params and route object", async () => { + const entries = await mpaEntriesFor(); + const [layout] = routesOfEntry(entryAt(entries, "docs/b.html")); + const page = layout!.children![0]!; + expect(isValidElement(page.component)).toBe(true); + const element = page.component as ReactElement; + expect(element.type).not.toBe(FakeSlot); + expect(element.props.params).toEqual({ slug: "b" }); + expect(element.props.route).toEqual({ id: page.id }); + // The layout above the dynamic segment sees no params. + const layoutElement = layout! + .component as ReactElement; + expect(layoutElement.type).toBe(serverLayout); + expect(layoutElement.props.params).toEqual({}); + expect(layoutElement.props.route).toEqual({ id: layout!.id }); + }); + + it("passes a Client Component as a component type so the router renders it with the matched params", async () => { + const entries = await mpaEntriesFor(); + const [layout] = routesOfEntry(entryAt(entries, "en/client.html")); + expect(layout!.path).toBe("/:lang"); + expect(layout!.component).toBe(clientLayout); + const page = layout!.children![0]!; + expect(page.path).toBe("/client"); + expect(page.component).toBe(clientPage); + }); + + it("registers no chunks, since nothing is fetched on navigation", async () => { + const { host, registered, restored } = fakeHost(); + const getEntries = createFsRoutesEntriesWithHost( + { modules, base: "./pages", root: Root, mode: "mpa" }, + host, + ); + for (const _ of [1, 2]) { + for await (const _entry of getEntries()) { + // drain + } + } + expect(registered).toEqual([]); + expect(restored).toEqual([]); + }); + + it("assigns every route the same id as in spa mode so route objects stay portable", async () => { + const spa = await entriesFor(modules); + const mpa = await mpaEntriesFor(); + const idByPath = (entry: EntryDefinition) => + new Map( + collectDefinitions(routesOfEntry(entry)).map((d) => [d.id, d.path]), + ); + const spaIds = idByPath(entryAt(spa, "docs/a.html")); + for (const entry of mpa) { + for (const [id, path] of idByPath(entry)) { + expect(spaIds.get(id), `id ${id} of ${entry.path}`).toBe(path); + } + } + }); +}); + describe("createFsRoutesEntries enumeration caching", () => { function factoryFor( modules: Record, diff --git a/packages/static/src/fs-routes/runtime.tsx b/packages/static/src/fs-routes/runtime.tsx index ba641a5..9a5cd61 100644 --- a/packages/static/src/fs-routes/runtime.tsx +++ b/packages/static/src/fs-routes/runtime.tsx @@ -4,7 +4,7 @@ import { type ReactElement, type ReactNode, } from "react"; -import { Router } from "@funstack/router"; +import { RouteDisplay, Router } from "@funstack/router"; import type { RouteDefinition } from "@funstack/router/server"; import type { FsRootComponent, @@ -27,6 +27,22 @@ import { import { isClientReference } from "../util/clientReference"; import { paramsKey, pickParams, type FsRouteSlotProps } from "./slot"; +/** + * How navigation between generated pages works. + * + * - `"spa"`: pages are rendered with FUNSTACK Router's ``, which + * intercepts link clicks for soft client-side navigation. Server Component + * output for every page is pre-rendered as RSC chunks the client fetches on + * navigation. + * - `"mpa"`: pages are rendered with ``, which never intercepts + * navigations: every link click loads the destination's own HTML. No RSC + * chunks are generated beyond each page's payload. + * + * @experimental File-system routing is experimental and not yet subject to + * semantic versioning. + */ +export type FsRoutesMode = "spa" | "mpa"; + /** * Options for {@link createFsRoutesEntries}. * @@ -61,6 +77,14 @@ export interface CreateFsRoutesOptions { * @default nextRoutes() */ adapter?: FsRoutesAdapter; + /** + * Whether the generated site is a single-page application with soft + * client-side navigation (`"spa"`) or a multi-page application where every + * navigation loads a new document (`"mpa"`). See {@link FsRoutesMode}. + * + * @default "spa" + */ + mode?: FsRoutesMode; } /** @@ -307,17 +331,77 @@ function buildRouteDefinitions( }); } +/** + * Builds the route definitions of one page in MPA mode: the page's own route + * chain, nested root-first, with every node's component inlined. + * + * Each page is its own document and `RouteDisplay` never navigates away from + * it, so only the chain the page renders through can match its URL; sibling + * routes are left out. Without soft navigation there is no destination whose + * params could differ from the page's own, so a Server Component's build-time + * output is inlined directly rather than through a slot resolving chunks. + */ +function buildMpaRouteDefinitions( + chain: readonly FsRouteTreeNode[], + metas: Map, + pageParams: Record, +): RouteDefinition[] { + let children: RouteDefinition[] | undefined; + for (let index = chain.length - 1; index >= 0; index--) { + const node = chain[index]!; + const meta = metas.get(node)!; + const Component = node.module.default; + const definition: { + id: string; + path?: string; + component?: ComponentType | ReactNode; + children?: RouteDefinition[]; + } = { id: meta.id }; + if (node.path !== undefined) { + definition.path = node.path; + } + if (Component) { + if (isClientReference(Component)) { + // Rendered by the router in the browser (and during SSR, via the + // `ssr` path) with the params of the current match. + definition.component = Component as ComponentType; + } else { + definition.component = createElement(Component, { + params: pickParams(pageParams, meta.paramNames), + route: meta.route, + }); + } + } + if (children) { + definition.children = children; + } + children = [definition]; + } + return children ?? []; +} + function FsRoutesApp({ + mode, tree, metas, page, RouteSlot, }: { + mode: FsRoutesMode; tree: FsRouteTreeNode[]; metas: Map; page: StaticPage; RouteSlot: ComponentType; }): ReactNode { + if (mode === "mpa") { + // RouteDisplay matches the page's URL (the `ssr` path during SSR and + // hydration, `window.location` afterwards) without ever intercepting a + // navigation, so links load the destination's own HTML. + return createElement(RouteDisplay, { + routes: buildMpaRouteDefinitions(page.chain, metas, page.params), + ssr: { path: page.urlPath }, + }); + } const routes = buildRouteDefinitions(tree, { metas, pageChain: new Set(page.chain), @@ -338,7 +422,9 @@ function FsRoutesApp({ * * The route tree is built once via the adapter; the router route definitions * are rebuilt per page so that the page's own Server Component output can be - * inlined into its payload. + * inlined into its payload. In `"mpa"` mode (see {@link FsRoutesMode}) each + * page's definitions cover only its own route chain, since no navigation ever + * renders another route within the same document. * * The enumeration itself (route tree, `generateStaticParams()` of every * dynamic route, chunk registration) runs once and is cached for the @@ -353,7 +439,13 @@ export function createFsRoutesEntriesWithHost( options: CreateFsRoutesOptions, host: FsRoutesRuntimeHost, ): () => GetEntriesResult { - const { modules, base, root: Root, adapter = nextRoutes() } = options; + const { + modules, + base, + root: Root, + adapter = nextRoutes(), + mode = "spa", + } = options; async function enumerateRoutes(): Promise { const warn = (message: string) => { @@ -365,7 +457,10 @@ export function createFsRoutesEntriesWithHost( const pages = await collectStaticPaths(tree); const metas = new Map(); buildNodeMetas(tree, [], "", metas); - const chunks = registerChunks(pages, metas, host); + // Chunks exist only to serve Server Component output on soft navigation; + // an MPA loads each destination's own HTML and payload instead. + const chunks = + mode === "spa" ? registerChunks(pages, metas, host) : new Map(); return { tree, metas, pages, chunks }; } @@ -407,6 +502,7 @@ export function createFsRoutesEntriesWithHost( path: urlPathToFilePath(page.urlPath), root: { default: Root }, app: createElement(FsRoutesApp, { + mode, tree, metas, page, diff --git a/packages/static/src/plugin/index.test.ts b/packages/static/src/plugin/index.test.ts index 3e5b2cc..ba5ad82 100644 --- a/packages/static/src/plugin/index.test.ts +++ b/packages/static/src/plugin/index.test.ts @@ -136,6 +136,39 @@ describe("virtual module path escaping", () => { expect(code).toContain('import adapter from "/pro\\"ject/src/adapter.ts";'); }); + it("emits the default spa mode in the fsRoutes entries module", () => { + const code = loadVirtualModule( + fsRoutesOptions, + "/project", + "\0virtual:funstack/entries", + ); + expect(code).toContain('mode: "spa",'); + }); + + it("emits the configured fsRoutes.mode in the fsRoutes entries module", () => { + const code = loadVirtualModule( + { fsRoutes: { ...fsRoutesOptions.fsRoutes!, mode: "mpa" } }, + "/project", + "\0virtual:funstack/entries", + ); + expect(code).toContain('mode: "mpa",'); + }); + + it("rejects an unknown fsRoutes.mode", () => { + expect(() => + loadVirtualModule( + { + fsRoutes: { + ...fsRoutesOptions.fsRoutes!, + mode: "ssg" as unknown as "spa", + }, + }, + "/project", + "\0virtual:funstack/entries", + ), + ).toThrow(/Invalid fsRoutes.mode: "ssg"/); + }); + it("rejects an fsRoutes.dir outside the Vite root", () => { for (const dir of ["../pages", "."]) { expect(() => diff --git a/packages/static/src/plugin/index.ts b/packages/static/src/plugin/index.ts index 6a91896..6a0f702 100644 --- a/packages/static/src/plugin/index.ts +++ b/packages/static/src/plugin/index.ts @@ -131,6 +131,22 @@ export interface FsRoutesConfig { * `@funstack/static/fs-routes`) in your own module and point this at it. */ adapter: string; + /** + * Whether the generated site is a single-page application or a multi-page + * application. + * + * - `"spa"` (default): pages are rendered with FUNSTACK Router's ``. + * Link clicks are intercepted for soft client-side navigation, and the + * Server Component output of every page is pre-rendered as RSC chunks + * that the client fetches on navigation. + * - `"mpa"`: pages are rendered with FUNSTACK Router's ``, + * which never intercepts navigations: every link click loads the + * destination's own HTML, like a classic multi-page site. Only each + * page's own RSC payload is generated. + * + * @default "spa" + */ + mode?: "spa" | "mpa"; } interface FsRoutesOptions { @@ -187,7 +203,8 @@ export default function funstackStatic( let resolvedBuildEntry: string | undefined; // Resolved configuration for file-system routing (fsRoutes mode). let resolvedFsRoutes: - { root: string; adapter: string; globBase: string } | undefined; + | { root: string; adapter: string; globBase: string; mode: "spa" | "mpa" } + | undefined; // Determine which entry mode the user selected. const isFsRoutes = "fsRoutes" in options && options.fsRoutes !== undefined; @@ -254,7 +271,14 @@ export default function funstackStatic( const adapter = isBareSpecifier(fsRoutes.adapter) ? fsRoutes.adapter : normalizePath(path.resolve(config.root, fsRoutes.adapter)); - resolvedFsRoutes = { root: resolvedRoot, adapter, globBase }; + const mode = fsRoutes.mode ?? "spa"; + if (mode !== "spa" && mode !== "mpa") { + throw new Error( + `[funstack] Invalid fsRoutes.mode: ${JSON.stringify(mode)}. ` + + `Expected "spa" or "mpa".`, + ); + } + resolvedFsRoutes = { root: resolvedRoot, adapter, globBase, mode }; } else if (isMultiEntry) { resolvedEntriesModule = normalizePath( path.resolve(config.root, options.entries), @@ -355,7 +379,7 @@ export default function funstackStatic( // The import.meta.glob call is emitted here so users don't have to // wire it up themselves; Vite transforms the glob in this virtual // module just like in user code. - const { root, adapter, globBase } = resolvedFsRoutes; + const { root, adapter, globBase, mode } = resolvedFsRoutes; const globPattern = `${globBase}/**/*.{tsx,jsx}`; const lines = [ `import Root from ${JSON.stringify(root)};`, @@ -367,6 +391,7 @@ export default function funstackStatic( ` base: ${JSON.stringify(globBase)},`, ` root: Root,`, ` adapter,`, + ` mode: ${JSON.stringify(mode)},`, `});`, ]; return lines.join("\n"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75da3b5..fe8796b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,6 +256,33 @@ importers: specifier: 'catalog:' version: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(terser@5.47.1)(tsx@4.21.0) + packages/static/e2e/fixture-fs-routing-mpa: + devDependencies: + '@funstack/router': + specifier: ^1.6.0 + version: 1.6.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@funstack/static': + specifier: workspace:* + version: link:../.. + '@types/react': + specifier: ^19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': + specifier: 'catalog:' + version: 6.1.2(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(terser@5.47.1)(tsx@4.21.0)) + react: + specifier: 'catalog:' + version: 19.3.1 + react-dom: + specifier: 'catalog:' + version: 19.3.1(react@19.3.1) + vite: + specifier: 'catalog:' + version: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(terser@5.47.1)(tsx@4.21.0) + packages/static/e2e/fixture-multi-entry: devDependencies: '@funstack/static': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3dc8dc0..aa1f7fb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - packages/static/e2e/fixture-multi-entry - packages/static/e2e/fixture-ssr-defer - packages/static/e2e/fixture-fs-routing + - packages/static/e2e/fixture-fs-routing-mpa - packages/static/e2e/fixture-render-error catalog: From 5c8b01d8af022f40d11683e08cf504ae454b311c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 12:10:53 +0000 Subject: [PATCH 2/3] feat(example): add file-system routing example in mpa mode Add `example-fs-routing-mpa`, a sibling of `example-fs-routing` configured with `fsRoutes.mode: "mpa"`. It shows that every link loads a new document (client-side state in the shell resets), that `useSearchParams`'s setter performs a full page navigation under ``, and that a dynamic Server Component page needs no route object to show the right params. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014ERdZEFjZXcbrh8pQttyc4 --- .../src/pages/learn/FileSystemRouting.mdx | 2 +- packages/example-fs-routing-mpa/package.json | 25 +++++ packages/example-fs-routing-mpa/src/index.css | 75 +++++++++++++++ .../src/pages/about/page.tsx | 21 +++++ .../src/pages/blog/[slug]/page.tsx | 40 ++++++++ .../src/pages/blog/layout.tsx | 18 ++++ .../src/pages/blog/page.tsx | 19 ++++ .../src/pages/blog/posts.ts | 30 ++++++ .../src/pages/blog/sort-control.tsx | 34 +++++++ .../example-fs-routing-mpa/src/pages/page.tsx | 93 +++++++++++++++++++ packages/example-fs-routing-mpa/src/root.tsx | 22 +++++ .../src/visit-counter.tsx | 18 ++++ packages/example-fs-routing-mpa/tsconfig.json | 18 ++++ .../example-fs-routing-mpa/vite.config.ts | 24 +++++ pnpm-lock.yaml | 31 +++++++ 15 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 packages/example-fs-routing-mpa/package.json create mode 100644 packages/example-fs-routing-mpa/src/index.css create mode 100644 packages/example-fs-routing-mpa/src/pages/about/page.tsx create mode 100644 packages/example-fs-routing-mpa/src/pages/blog/[slug]/page.tsx create mode 100644 packages/example-fs-routing-mpa/src/pages/blog/layout.tsx create mode 100644 packages/example-fs-routing-mpa/src/pages/blog/page.tsx create mode 100644 packages/example-fs-routing-mpa/src/pages/blog/posts.ts create mode 100644 packages/example-fs-routing-mpa/src/pages/blog/sort-control.tsx create mode 100644 packages/example-fs-routing-mpa/src/pages/page.tsx create mode 100644 packages/example-fs-routing-mpa/src/root.tsx create mode 100644 packages/example-fs-routing-mpa/src/visit-counter.tsx create mode 100644 packages/example-fs-routing-mpa/tsconfig.json create mode 100644 packages/example-fs-routing-mpa/vite.config.ts diff --git a/packages/docs/src/pages/learn/FileSystemRouting.mdx b/packages/docs/src/pages/learn/FileSystemRouting.mdx index 14ba878..0c24709 100644 --- a/packages/docs/src/pages/learn/FileSystemRouting.mdx +++ b/packages/docs/src/pages/learn/FileSystemRouting.mdx @@ -264,7 +264,7 @@ export default nextRoutes({ pageFileName: "index", layoutFileName: "_layout" }); ## Full Example -For a complete working example, see the [`example-fs-routing`](https://github.com/uhyo/funstack-static/tree/master/packages/example-fs-routing) package in the FUNSTACK Static repository. +For a complete working example, see the [`example-fs-routing`](https://github.com/uhyo/funstack-static/tree/master/packages/example-fs-routing) package in the FUNSTACK Static repository. The [`example-fs-routing-mpa`](https://github.com/uhyo/funstack-static/tree/master/packages/example-fs-routing-mpa) package shows the same routing in [MPA mode](#spa-or-mpa). ## Fully Custom Routing diff --git a/packages/example-fs-routing-mpa/package.json b/packages/example-fs-routing-mpa/package.json new file mode 100644 index 0000000..a31343a --- /dev/null +++ b/packages/example-fs-routing-mpa/package.json @@ -0,0 +1,25 @@ +{ + "name": "funstack-static-example-fs-routing-mpa", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@funstack/router": "^1.6.0", + "@funstack/static": "workspace:*", + "@types/node": "catalog:", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "catalog:", + "vite": "catalog:" + } +} diff --git a/packages/example-fs-routing-mpa/src/index.css b/packages/example-fs-routing-mpa/src/index.css new file mode 100644 index 0000000..9ffeace --- /dev/null +++ b/packages/example-fs-routing-mpa/src/index.css @@ -0,0 +1,75 @@ +:root { + font-family: + system-ui, + -apple-system, + sans-serif; + line-height: 1.6; + color: #213547; + background-color: #ffffff; +} + +@media (prefers-color-scheme: dark) { + :root { + color: #ffffffde; + background-color: #242424; + } + + a { + color: #6db3f2; + } +} + +body { + max-width: 720px; + margin: 0 auto; + padding: 2rem; +} + +nav { + padding-bottom: 1rem; + margin-bottom: 2rem; + border-bottom: 1px solid #ddd; +} + +@media (prefers-color-scheme: dark) { + nav { + border-bottom-color: #444; + } +} + +nav a { + margin: 0 0.25rem; +} + +code { + background: #f4f4f4; + padding: 0.15em 0.3em; + border-radius: 3px; + font-size: 0.9em; +} + +@media (prefers-color-scheme: dark) { + code { + background: #333; + } +} + +table { + border-collapse: collapse; + width: 100%; +} + +th, +td { + text-align: left; + vertical-align: top; + padding: 0.4em 0.6em; + border-bottom: 1px solid #ddd; +} + +@media (prefers-color-scheme: dark) { + th, + td { + border-bottom-color: #444; + } +} diff --git a/packages/example-fs-routing-mpa/src/pages/about/page.tsx b/packages/example-fs-routing-mpa/src/pages/about/page.tsx new file mode 100644 index 0000000..7624043 --- /dev/null +++ b/packages/example-fs-routing-mpa/src/pages/about/page.tsx @@ -0,0 +1,21 @@ +export default function About() { + return ( +
+

About

+

+ This example demonstrates the built-in file-system routing of{" "} + FUNSTACK Static{" "} + configured as a multi-page application ( + mode: "mpa" + ). +

+

+ Routes are derived from the file structure under src/pages/{" "} + using the Next.js-like adapter and rendered with FUNSTACK Router's{" "} + <RouteDisplay>. Each generated page is a standalone + document: following a link loads the destination's HTML, exactly as + a plain HTML link would. +

+
+ ); +} diff --git a/packages/example-fs-routing-mpa/src/pages/blog/[slug]/page.tsx b/packages/example-fs-routing-mpa/src/pages/blog/[slug]/page.tsx new file mode 100644 index 0000000..29a80f6 --- /dev/null +++ b/packages/example-fs-routing-mpa/src/pages/blog/[slug]/page.tsx @@ -0,0 +1,40 @@ +import type { FsRouteComponentProps } from "@funstack/static/fs-routes"; +import { getPost, posts } from "../posts"; + +// A dynamic route must enumerate its pages at build time. Each returned +// object becomes one pre-rendered page: `/blog/hello`, `/blog/static-generation`, … +export function generateStaticParams() { + return posts.map((post) => ({ slug: post.slug })); +} + +// A Server Component page, rendered once per params combination at build +// time. In MPA mode the document never outlives its URL, so `params` always +// matches the URL shown: there is no need for the route object and +// `useRouteParams` to read "live" params, as the SPA example does. +export default function BlogPost({ + params, +}: FsRouteComponentProps<{ slug: string }>) { + const post = getPost(params.slug); + const index = posts.findIndex((p) => p.slug === params.slug); + const prev = posts[index - 1]; + const next = posts[index + 1]; + + return ( +
+

{post?.title ?? "Unknown post"}

+

+ params.slug of this page: {params.slug} +

+

{post?.body}

+

Follow the links below: each post is loaded as its own document.

+

+ {prev ? ← {prev.title} : null} + {prev && next ? " | " : null} + {next ? {next.title} → : null} +

+

+ Back to the blog index +

+
+ ); +} diff --git a/packages/example-fs-routing-mpa/src/pages/blog/layout.tsx b/packages/example-fs-routing-mpa/src/pages/blog/layout.tsx new file mode 100644 index 0000000..58b1081 --- /dev/null +++ b/packages/example-fs-routing-mpa/src/pages/blog/layout.tsx @@ -0,0 +1,18 @@ +import { Outlet } from "@funstack/router"; + +// A Server Component layout: `blog/layout.tsx` wraps every page under +// `blog/`. It renders `` where the matched child page should +// appear. In MPA mode the layout is rendered anew with each page's document; +// there is no shared layout instance persisting across navigations. +export default function BlogLayout() { + return ( +
+

+ + Everything below is wrapped by pages/blog/layout.tsx. + +

+ +
+ ); +} diff --git a/packages/example-fs-routing-mpa/src/pages/blog/page.tsx b/packages/example-fs-routing-mpa/src/pages/blog/page.tsx new file mode 100644 index 0000000..9227947 --- /dev/null +++ b/packages/example-fs-routing-mpa/src/pages/blog/page.tsx @@ -0,0 +1,19 @@ +import { posts } from "./posts"; +import { SortControl } from "./sort-control"; + +// A Server Component page listing the posts. The sort order comes from the +// URL's query string, which a Client Component reads and changes; see +// `sort-control.tsx` for how that works without client-side routing. +export default function Blog() { + return ( +
+

Blog

+

+ This page is at pages/blog/page.tsx, which maps to the{" "} + /blog route. The posts below live at{" "} + pages/blog/[slug]/page.tsx, a dynamic route. +

+ +
+ ); +} diff --git a/packages/example-fs-routing-mpa/src/pages/blog/posts.ts b/packages/example-fs-routing-mpa/src/pages/blog/posts.ts new file mode 100644 index 0000000..7efb7f4 --- /dev/null +++ b/packages/example-fs-routing-mpa/src/pages/blog/posts.ts @@ -0,0 +1,30 @@ +// Shared data for the blog routes. This file is neither `page.tsx` nor +// `layout.tsx`, so the adapter ignores it: helpers can be co-located with +// routes. +export interface Post { + slug: string; + title: string; + body: string; +} + +export const posts: Post[] = [ + { + slug: "hello", + title: "Hello, World", + body: "The first post. Each post is a page of the dynamic `/blog/:slug` route, pre-rendered at build time.", + }, + { + slug: "static-generation", + title: "Static Generation", + body: "A static site can only serve pages that exist at build time, so dynamic routes enumerate their params with generateStaticParams().", + }, + { + slug: "full-page-loads", + title: "Full Page Loads", + body: "Following a link between posts loads the destination's own HTML. There is no soft navigation, so no RSC chunk is fetched and nothing from the previous page is kept.", + }, +]; + +export function getPost(slug: string): Post | undefined { + return posts.find((post) => post.slug === slug); +} diff --git a/packages/example-fs-routing-mpa/src/pages/blog/sort-control.tsx b/packages/example-fs-routing-mpa/src/pages/blog/sort-control.tsx new file mode 100644 index 0000000..64388ff --- /dev/null +++ b/packages/example-fs-routing-mpa/src/pages/blog/sort-control.tsx @@ -0,0 +1,34 @@ +"use client"; +import { useSearchParams } from "@funstack/router"; +import type { Post } from "./posts"; + +// FUNSTACK Router's hooks that read the location work under `` +// just as under ``. Setters that navigate, such as the one returned by +// `useSearchParams`, perform a full page navigation in MPA mode: changing the +// sort order below reloads `/blog?sort=…`, and this component renders the +// new order from the URL of the freshly loaded document. +export function SortControl({ posts }: { posts: Post[] }) { + const [searchParams, setSearchParams] = useSearchParams(); + const descending = searchParams.get("sort") === "desc"; + const sorted = descending ? [...posts].reverse() : posts; + return ( +
+

+ Order:{" "} + +

+ +
+ ); +} diff --git a/packages/example-fs-routing-mpa/src/pages/page.tsx b/packages/example-fs-routing-mpa/src/pages/page.tsx new file mode 100644 index 0000000..2453326 --- /dev/null +++ b/packages/example-fs-routing-mpa/src/pages/page.tsx @@ -0,0 +1,93 @@ +export default function Home() { + return ( +
+

Home

+

+ Welcome to the file-system routing example in MPA mode! + Pages in src/pages/ are mapped to routes by{" "} + @funstack/static's built-in file-system routing, as in + the example-fs-routing package. The difference is one line + in vite.config.ts: mode: "mpa". +

+

What MPA mode changes

+
    +
  • + Pages are rendered with FUNSTACK Router's{" "} + <RouteDisplay> instead of{" "} + <Router>. It never intercepts navigations, so every + link performs a regular full page load of the destination's own + HTML. +
  • +
  • + The build emits one HTML file and one RSC payload per page, and + nothing else: no pre-rendered chunks are needed, because nothing is + fetched on navigation. +
  • +
  • + Client-side state does not survive navigation. Click the button in the + navigation bar a few times, then follow a link: it starts over. +
  • +
+

Routes in this example

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileRouteDemonstrates
+ pages/page.tsx + + / + A page for the root directory
+ pages/about/page.tsx + + /about + A static route
+ pages/blog/layout.tsx + — + A layout wrapping every /blog/… page +
+ pages/blog/page.tsx + + /blog + The blog index, reading the URL in a Client Component
+ pages/blog/[slug]/page.tsx + + /blog/:slug + + A dynamic Server Component page with{" "} + generateStaticParams +
+
+ ); +} diff --git a/packages/example-fs-routing-mpa/src/root.tsx b/packages/example-fs-routing-mpa/src/root.tsx new file mode 100644 index 0000000..8d7006c --- /dev/null +++ b/packages/example-fs-routing-mpa/src/root.tsx @@ -0,0 +1,22 @@ +import type React from "react"; +import "./index.css"; +import { VisitCounter } from "./visit-counter"; + +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + + + + FUNSTACK Static - File-System Routing (MPA) + + + +
{children}
+ + + ); +} diff --git a/packages/example-fs-routing-mpa/src/visit-counter.tsx b/packages/example-fs-routing-mpa/src/visit-counter.tsx new file mode 100644 index 0000000..6b2d8ad --- /dev/null +++ b/packages/example-fs-routing-mpa/src/visit-counter.tsx @@ -0,0 +1,18 @@ +"use client"; +import { useState } from "react"; + +// Client-side state in the shell. In an SPA this state would survive +// navigation between pages; in this MPA every link loads a new document, so +// the counter starts over on each page. +export function VisitCounter() { + const [count, setCount] = useState(0); + return ( + + ); +} diff --git a/packages/example-fs-routing-mpa/tsconfig.json b/packages/example-fs-routing-mpa/tsconfig.json new file mode 100644 index 0000000..bb5d780 --- /dev/null +++ b/packages/example-fs-routing-mpa/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "jsx": "react-jsx" + } +} diff --git a/packages/example-fs-routing-mpa/vite.config.ts b/packages/example-fs-routing-mpa/vite.config.ts new file mode 100644 index 0000000..8d935bb --- /dev/null +++ b/packages/example-fs-routing-mpa/vite.config.ts @@ -0,0 +1,24 @@ +import funstackStatic from "@funstack/static"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + funstackStatic({ + ssr: true, + // Built-in file-system routing in MPA mode: pages under `src/pages` are + // mapped to routes via the Next.js-like adapter and rendered with + // FUNSTACK Router's ``, which never intercepts + // navigations. Every link loads the destination's own HTML, like a + // classic multi-page site. Compare with `example-fs-routing`, which + // uses the default "spa" mode with soft client-side navigation. + fsRoutes: { + dir: "./src/pages", + root: "./src/root.tsx", + adapter: "@funstack/static/fs-routes/next-adapter", + mode: "mpa", + }, + }), + react(), + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe8796b..35e9828 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,6 +153,37 @@ importers: specifier: 'catalog:' version: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(terser@5.47.1)(tsx@4.21.0) + packages/example-fs-routing-mpa: + dependencies: + '@funstack/router': + specifier: ^1.6.0 + version: 1.6.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@funstack/static': + specifier: workspace:* + version: link:../static + '@types/node': + specifier: 'catalog:' + version: 26.6.5 + react: + specifier: 'catalog:' + version: 19.3.1 + react-dom: + specifier: 'catalog:' + version: 19.3.1(react@19.3.1) + devDependencies: + '@types/react': + specifier: ^19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': + specifier: 'catalog:' + version: 6.1.2(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(terser@5.47.1)(tsx@4.21.0)) + vite: + specifier: 'catalog:' + version: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(terser@5.47.1)(tsx@4.21.0) + packages/static: dependencies: '@funstack/skill-installer': From b4088eefbbfc65d9bbc74138dfadf4fb0e8973cf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 13:24:13 +0000 Subject: [PATCH 3/3] feat!: require fsRoutes.mode Make `mode` a required field of `fsRoutes`, like `dir`, `root` and `adapter`, so the choice between soft client-side navigation ("spa") and full page loads ("mpa") is explicit in every config. The plugin fails with a clear error when it is missing. `createFsRoutesEntries` keeps its "spa" default, mirroring how `adapter` is optional there. File-system routing is experimental, so this is not a semver-breaking change for the package. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014ERdZEFjZXcbrh8pQttyc4 --- .../docs/src/pages/api/FunstackStatic.mdx | 6 ++-- .../src/pages/learn/FileSystemRouting.mdx | 15 ++++---- packages/example-fs-routing/vite.config.ts | 1 + .../e2e/fixture-fs-routing/vite.config.ts | 1 + packages/static/src/fs-routes/next-adapter.ts | 1 + packages/static/src/plugin/index.test.ts | 35 +++++++++++-------- packages/static/src/plugin/index.ts | 23 ++++++------ 7 files changed, 49 insertions(+), 33 deletions(-) diff --git a/packages/docs/src/pages/api/FunstackStatic.mdx b/packages/docs/src/pages/api/FunstackStatic.mdx index b510830..ab1a73e 100644 --- a/packages/docs/src/pages/api/FunstackStatic.mdx +++ b/packages/docs/src/pages/api/FunstackStatic.mdx @@ -73,6 +73,7 @@ export default defineConfig({ dir: "./src/pages", root: "./src/root.tsx", adapter: "@funstack/static/fs-routes/next-adapter", + mode: "spa", }, }), react(), @@ -205,16 +206,17 @@ funstackStatic({ dir: "./src/pages", root: "./src/root.tsx", adapter: "@funstack/static/fs-routes/next-adapter", + mode: "spa", }, }); ``` -`FsRoutesConfig` fields: +`FsRoutesConfig` fields (all required — you must be explicit about each): - **`dir`** (required) — directory scanned for route files, relative to the Vite root. Commonly `"./src/pages"`. - **`root`** (required) — path to the root (HTML shell) component module. - **`adapter`** (required) — a module that `export default`s an `FsRoutesAdapter`, given as a bare module specifier or a path relative to the Vite root. Use the bundled `@funstack/static/fs-routes/next-adapter` for the built-in Next.js-like convention, or point it at your own module for a custom one. -- **`mode`** (optional, default `"spa"`) — `"spa"` renders pages with FUNSTACK Router's ``, so links within the site are followed by soft client-side navigation (with the Server Component output of every page pre-rendered as RSC chunks fetched on navigation). `"mpa"` renders pages with ``, which never intercepts navigations: every link loads the destination's own HTML, and only each page's own RSC payload is emitted. See [SPA or MPA](/learn/file-system-routing#spa-or-mpa). +- **`mode`** (required) — `"spa"` renders pages with FUNSTACK Router's ``, so links within the site are followed by soft client-side navigation (with the Server Component output of every page pre-rendered as RSC chunks fetched on navigation). `"mpa"` renders pages with ``, which never intercepts navigations: every link loads the destination's own HTML, and only each page's own RSC payload is emitted. See [SPA or MPA](/learn/file-system-routing#spa-or-mpa). Enabling [`ssr`](#ssr-optional) alongside `fsRoutes` is optional but recommended for SEO and faster initial load. diff --git a/packages/docs/src/pages/learn/FileSystemRouting.mdx b/packages/docs/src/pages/learn/FileSystemRouting.mdx index 0c24709..fef4e81 100644 --- a/packages/docs/src/pages/learn/FileSystemRouting.mdx +++ b/packages/docs/src/pages/learn/FileSystemRouting.mdx @@ -32,6 +32,7 @@ export default defineConfig({ dir: "./src/pages", root: "./src/root.tsx", adapter: "@funstack/static/fs-routes/next-adapter", + mode: "spa", }, }), react(), @@ -39,13 +40,12 @@ export default defineConfig({ }); ``` -These `fsRoutes` fields are required, so each choice is explicit in your config: +All `fsRoutes` fields are required, so each choice is explicit in your config: - `dir` — the directory scanned for route files (commonly `./src/pages`). - `root` — the HTML shell component (`…{children}`). - `adapter` — a module that `export default`s the adapter defining the directory / file-name convention. The bundled `@funstack/static/fs-routes/next-adapter` provides the built-in Next.js-like convention with default options; see [Custom Conventions](#custom-conventions-adapters) to configure it or write your own. - -Optionally, `mode` chooses between a single-page application with soft client-side navigation (`"spa"`, the default) and a multi-page application where every link loads a new document (`"mpa"`). See [SPA or MPA](#spa-or-mpa). +- `mode` — `"spa"` for a single-page application with soft client-side navigation, or `"mpa"` for a multi-page application where every link loads a new document. See [SPA or MPA](#spa-or-mpa). `fsRoutes` is mutually exclusive with the `root` + `app` (single-entry) and `entries` (multiple entries) modes. @@ -144,7 +144,7 @@ export { default } from "./_page"; // _page.tsx is marked "use client" ## SPA or MPA -Every route becomes its own HTML file either way, so any page can be loaded directly, and the site works on any static host. The `mode` option decides what happens when the visitor follows a link _within_ the site: +Every route becomes its own HTML file either way, so any page can be loaded directly, and the site works on any static host. The required `mode` option decides what happens when the visitor follows a link _within_ the site: ```typescript funstackStatic({ @@ -152,19 +152,19 @@ funstackStatic({ dir: "./src/pages", root: "./src/root.tsx", adapter: "@funstack/static/fs-routes/next-adapter", - mode: "mpa", // or "spa" (the default) + mode: "mpa", // or "spa" }, }); ``` -- **`"spa"`** (default) — pages are rendered with FUNSTACK Router's [``](https://router.funstack.work/api/components). Clicking a link to another page is intercepted and handled as a **soft client-side navigation**: the document stays, layouts shared with the destination keep their state, and only the changed part of the page is rendered. The build pre-renders the Server Component output of every page as static RSC chunks that the client fetches on navigation (see [Params on Client-Side Navigation](#params-on-client-side-navigation) for the details). +- **`"spa"`** — pages are rendered with FUNSTACK Router's [``](https://router.funstack.work/api/components). Clicking a link to another page is intercepted and handled as a **soft client-side navigation**: the document stays, layouts shared with the destination keep their state, and only the changed part of the page is rendered. The build pre-renders the Server Component output of every page as static RSC chunks that the client fetches on navigation (see [Params on Client-Side Navigation](#params-on-client-side-navigation) for the details). - **`"mpa"`** — pages are rendered with FUNSTACK Router's [``](https://router.funstack.work/api/components), which never intercepts navigations. Clicking a link performs a regular **full page load** of the destination's own HTML, like a classic multi-page site; nothing is fetched on navigation, and the build emits only each page's own RSC payload. Pick this when you do not want client-side routing — for example for a content site where each page should stand alone, or to keep the behavior of plain HTML links. Pages and layouts are written the same way in both modes: a Client Component always receives the params of the URL being shown, and a Server Component the params it was generated with (which in MPA mode are always the same, since the document never outlives its URL). FUNSTACK Router's hooks that read the location (`useLocation`, `useSearchParams`, `useRouteParams`, …) work in both modes, but in MPA mode features that depend on intercepting navigations are inert — `useBlocker` never blocks, and a setter that changes the URL (such as the one from `useSearchParams`) performs a full page navigation. ## Params on Client-Side Navigation -> This section describes the default `"spa"` [mode](#spa-or-mpa). In `"mpa"` mode there is no client-side navigation: every page is loaded directly, so `params` always match the URL. +> This section describes the `"spa"` [mode](#spa-or-mpa). In `"mpa"` mode there is no client-side navigation: every page is loaded directly, so `params` always match the URL. 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: @@ -232,6 +232,7 @@ funstackStatic({ dir: "./src/pages", root: "./src/root.tsx", adapter: "./src/my-adapter.ts", + mode: "spa", }, }); ``` diff --git a/packages/example-fs-routing/vite.config.ts b/packages/example-fs-routing/vite.config.ts index e1a400b..80cdefe 100644 --- a/packages/example-fs-routing/vite.config.ts +++ b/packages/example-fs-routing/vite.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ dir: "./src/pages", root: "./src/root.tsx", adapter: "@funstack/static/fs-routes/next-adapter", + mode: "spa", }, }), react(), diff --git a/packages/static/e2e/fixture-fs-routing/vite.config.ts b/packages/static/e2e/fixture-fs-routing/vite.config.ts index 4d22f5b..0d7f0c2 100644 --- a/packages/static/e2e/fixture-fs-routing/vite.config.ts +++ b/packages/static/e2e/fixture-fs-routing/vite.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ dir: "./src/pages", root: "./src/root.tsx", adapter: "@funstack/static/fs-routes/next-adapter", + mode: "spa", }, }), react(), diff --git a/packages/static/src/fs-routes/next-adapter.ts b/packages/static/src/fs-routes/next-adapter.ts index ae58a7f..3e3b43f 100644 --- a/packages/static/src/fs-routes/next-adapter.ts +++ b/packages/static/src/fs-routes/next-adapter.ts @@ -11,6 +11,7 @@ * dir: "./src/pages", * root: "./src/root.tsx", * adapter: "@funstack/static/fs-routes/next-adapter", + * mode: "spa", * }, * }); * ``` diff --git a/packages/static/src/plugin/index.test.ts b/packages/static/src/plugin/index.test.ts index ba5ad82..138c93f 100644 --- a/packages/static/src/plugin/index.test.ts +++ b/packages/static/src/plugin/index.test.ts @@ -46,6 +46,7 @@ const fsRoutesOptions: FunstackStaticOptions = { dir: "./src/pages", root: "./src/root.tsx", adapter: "@funstack/static/fs-routes/next-adapter", + mode: "spa", }, }; @@ -127,6 +128,7 @@ describe("virtual module path escaping", () => { dir: "./src/pages", root: "./src/root.tsx", adapter: "./src/adapter.ts", + mode: "spa", }, }, trickyRoot, @@ -136,22 +138,26 @@ describe("virtual module path escaping", () => { expect(code).toContain('import adapter from "/pro\\"ject/src/adapter.ts";'); }); - it("emits the default spa mode in the fsRoutes entries module", () => { - const code = loadVirtualModule( - fsRoutesOptions, - "/project", - "\0virtual:funstack/entries", - ); - expect(code).toContain('mode: "spa",'); + it("emits the configured fsRoutes.mode in the fsRoutes entries module", () => { + for (const mode of ["spa", "mpa"] as const) { + const code = loadVirtualModule( + { fsRoutes: { ...fsRoutesOptions.fsRoutes!, mode } }, + "/project", + "\0virtual:funstack/entries", + ); + expect(code).toContain(`mode: "${mode}",`); + } }); - it("emits the configured fsRoutes.mode in the fsRoutes entries module", () => { - const code = loadVirtualModule( - { fsRoutes: { ...fsRoutesOptions.fsRoutes!, mode: "mpa" } }, - "/project", - "\0virtual:funstack/entries", - ); - expect(code).toContain('mode: "mpa",'); + it("rejects a missing fsRoutes.mode", () => { + const { mode: _mode, ...withoutMode } = fsRoutesOptions.fsRoutes!; + expect(() => + loadVirtualModule( + { fsRoutes: withoutMode as unknown as typeof fsRoutesOptions.fsRoutes }, + "/project", + "\0virtual:funstack/entries", + ), + ).toThrow(/fsRoutes\.mode is required/); }); it("rejects an unknown fsRoutes.mode", () => { @@ -178,6 +184,7 @@ describe("virtual module path escaping", () => { dir, root: "./src/root.tsx", adapter: "./src/adapter.ts", + mode: "spa", }, }, "/project", diff --git a/packages/static/src/plugin/index.ts b/packages/static/src/plugin/index.ts index 6a0f702..7140275 100644 --- a/packages/static/src/plugin/index.ts +++ b/packages/static/src/plugin/index.ts @@ -135,18 +135,16 @@ export interface FsRoutesConfig { * Whether the generated site is a single-page application or a multi-page * application. * - * - `"spa"` (default): pages are rendered with FUNSTACK Router's ``. - * Link clicks are intercepted for soft client-side navigation, and the - * Server Component output of every page is pre-rendered as RSC chunks - * that the client fetches on navigation. + * - `"spa"`: pages are rendered with FUNSTACK Router's ``. Link + * clicks are intercepted for soft client-side navigation, and the Server + * Component output of every page is pre-rendered as RSC chunks that the + * client fetches on navigation. * - `"mpa"`: pages are rendered with FUNSTACK Router's ``, * which never intercepts navigations: every link click loads the * destination's own HTML, like a classic multi-page site. Only each * page's own RSC payload is generated. - * - * @default "spa" */ - mode?: "spa" | "mpa"; + mode: "spa" | "mpa"; } interface FsRoutesOptions { @@ -271,11 +269,16 @@ export default function funstackStatic( const adapter = isBareSpecifier(fsRoutes.adapter) ? fsRoutes.adapter : normalizePath(path.resolve(config.root, fsRoutes.adapter)); - const mode = fsRoutes.mode ?? "spa"; + // Required (like the other fsRoutes fields) so the choice between + // soft client-side navigation and full page loads is explicit. + const mode: unknown = fsRoutes.mode; if (mode !== "spa" && mode !== "mpa") { throw new Error( - `[funstack] Invalid fsRoutes.mode: ${JSON.stringify(mode)}. ` + - `Expected "spa" or "mpa".`, + mode === undefined + ? `[funstack] fsRoutes.mode is required. Set it to "spa" ` + + `(soft client-side navigation) or "mpa" (full page loads).` + : `[funstack] Invalid fsRoutes.mode: ${JSON.stringify(mode)}. ` + + `Expected "spa" or "mpa".`, ); } resolvedFsRoutes = { root: resolvedRoot, adapter, globBase, mode };