diff --git a/packages/docs/src/pages/api/FunstackStatic.mdx b/packages/docs/src/pages/api/FunstackStatic.mdx index 4c3852e..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,6 +206,7 @@ funstackStatic({ dir: "./src/pages", root: "./src/root.tsx", adapter: "@funstack/static/fs-routes/next-adapter", + mode: "spa", }, }); ``` @@ -214,6 +216,7 @@ funstackStatic({ - **`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`** (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 9e8010a..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(), @@ -44,6 +45,7 @@ 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. +- `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. @@ -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 required `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" + }, +}); +``` + +- **`"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 `"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. @@ -208,6 +232,7 @@ funstackStatic({ dir: "./src/pages", root: "./src/root.tsx", adapter: "./src/my-adapter.ts", + mode: "spa", }, }); ``` @@ -240,7 +265,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/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-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/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/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/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/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..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,6 +138,43 @@ describe("virtual module path escaping", () => { expect(code).toContain('import adapter from "/pro\\"ject/src/adapter.ts";'); }); + 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("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", () => { + 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(() => @@ -145,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 6a91896..7140275 100644 --- a/packages/static/src/plugin/index.ts +++ b/packages/static/src/plugin/index.ts @@ -131,6 +131,20 @@ 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"`: 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. + */ + mode: "spa" | "mpa"; } interface FsRoutesOptions { @@ -187,7 +201,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 +269,19 @@ export default function funstackStatic( const adapter = isBareSpecifier(fsRoutes.adapter) ? fsRoutes.adapter : normalizePath(path.resolve(config.root, fsRoutes.adapter)); - resolvedFsRoutes = { root: resolvedRoot, adapter, globBase }; + // 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( + 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 }; } else if (isMultiEntry) { resolvedEntriesModule = normalizePath( path.resolve(config.root, options.entries), @@ -355,7 +382,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 +394,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..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': @@ -256,6 +287,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: