From 658b1b43c2fd83f7979feb347bbe618fa45bd2f3 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 29 Sep 2026 21:14:10 -0700 Subject: [PATCH 1/6] examples: hackernews collapse as a binding slot, twins in the authoring layout The thread's collapse was a `Toggle` client component wrapping a template slot. It is now a binding slot on server markup: the recursive `Comment` is a server component, and each comment with replies calls `props.toggle({ $key: c.id })` and binds the toggle's `open` class name, its `onClick`, its label (a text position) and the replies' `display`. The fill is the SPA twin's `Toggle` without its markup: a signal in the body, getters over it, a plain handler. No client components remain. Both twins take the authoring layout: each route file holds its query with the server function inline (markup in one twin, data in the other), and `src/server/hn.ts` is a plain module behind `import "server-only"`, with the boundary types referenced from `vite-env.d.ts` (TypeScript 6 rejects the undeclared side-effect import). `lib/`, `api.ts`, `views.tsx` and `components/toggle.tsx` go. `CommentDefinition` gains `id` and `StoryDefinition.id` is a number, as the data carries. The READMEs lead with the collapse, name their coordinates, and correct the bundle check (grep the client JavaScript; `app.css` carries both class names). A server spec pins the class-array form at a binding position on both faces. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- documentation/plans/examples-grid-plan.md | 21 +- examples/hackernews-spa/README.md | 25 ++- examples/hackernews-spa/src/lib/api.ts | 14 -- .../hackernews-spa/src/routes/stories.tsx | 12 +- examples/hackernews-spa/src/routes/story.tsx | 14 +- examples/hackernews-spa/src/routes/user.tsx | 14 +- .../lib => hackernews-spa/src/server}/hn.ts | 9 +- .../src/{lib => server}/story-30186326.json | 0 examples/hackernews-spa/src/types.ts | 3 +- examples/hackernews-spa/src/vite-env.d.ts | 1 + examples/hackernews/README.md | 111 +++++++---- examples/hackernews/src/app.tsx | 47 ++++- examples/hackernews/src/components/toggle.tsx | 16 -- examples/hackernews/src/lib/api.ts | 12 -- examples/hackernews/src/lib/views.tsx | 184 ------------------ examples/hackernews/src/routes/stories.tsx | 75 ++++++- examples/hackernews/src/routes/story.tsx | 104 +++++++++- examples/hackernews/src/routes/user.tsx | 32 ++- .../src/lib => hackernews/src/server}/hn.ts | 9 +- .../src/{lib => server}/story-30186326.json | 0 examples/hackernews/src/types.ts | 3 +- examples/hackernews/src/vite-env.d.ts | 1 + .../test/server/frame-binding-slots.spec.tsx | 53 +++++ 23 files changed, 443 insertions(+), 317 deletions(-) delete mode 100644 examples/hackernews-spa/src/lib/api.ts rename examples/{hackernews/src/lib => hackernews-spa/src/server}/hn.ts (85%) rename examples/hackernews-spa/src/{lib => server}/story-30186326.json (100%) delete mode 100644 examples/hackernews/src/components/toggle.tsx delete mode 100644 examples/hackernews/src/lib/api.ts delete mode 100644 examples/hackernews/src/lib/views.tsx rename examples/{hackernews-spa/src/lib => hackernews/src/server}/hn.ts (85%) rename examples/hackernews/src/{lib => server}/story-30186326.json (100%) diff --git a/documentation/plans/examples-grid-plan.md b/documentation/plans/examples-grid-plan.md index dfd15ff97..183be444e 100644 --- a/documentation/plans/examples-grid-plan.md +++ b/documentation/plans/examples-grid-plan.md @@ -96,6 +96,12 @@ SSR compile, so neither depends on the directive's level; a non-exported wrapper registers exactly as an exported one. The first example PR confirms with its build: passing with the `server-only` markers in place (pruning precedes resolution) and failing on a deliberate client import. +_Confirmed by `hackernews`' build (2026-09-29): both hold, the references +are named from their bindings (`getStory-`, the same in both twins), +and a module with the marker needs +`/// ` in +`vite-env.d.ts`, since TypeScript 6 rejects an undeclared side-effect +import (TS2882)._ ## The map @@ -127,7 +133,7 @@ owning it (`chat`). ## Per-example disposition -### `hackernews` — bottom-left, reads. KEEP; collapse → binding slot, layout, README. Blocked on G1, G2 +### `hackernews` — bottom-left, reads. KEEP; collapse → binding slot, layout, README. Built 2026-09-29 The front door: the simplest server component, navigation over server markup, a single stateful client concern. Its layering moment is comment @@ -155,7 +161,15 @@ gains the `id` the data already carries; the README's "`$key` keeps it attached" claim becomes true (today no key is passed); the bundle check can grep `comment-children` too. -### `hackernews-spa` — top-left. KEEP; layout only +_As built:_ the shape above, with `StoryDefinition.id` also typed `number` +(the capture and the live API both carry numbers). The thread's markup is +byte-identical to the twin's with hydration and slot markers stripped +(feed and user pages too); 652 of the 1,406 comments have replies, so 652 +fills. In the browser the collapsed state keeps its element and text node +through a `revalidate("story")` refetch. The bundle check greps the client +JavaScript, since `app.css` carries both class names. + +### `hackernews-spa` — top-left. KEEP; layout only. Built 2026-09-29 The twin; exists only as the comparison. README names the coordinate. Takes the authoring layout: each route file's `query` carries its server function @@ -371,7 +385,8 @@ examples that are already right carry the most value per hour._ 1. G2 (binding-slot execution), design then code. 2. G1 (text positions), design then code. -3. `hackernews` in the reviewed shape, with `hackernews-spa`'s layout. +3. `hackernews` in the reviewed shape, with `hackernews-spa`'s layout + (built 2026-09-29). 4. README and authoring-layout pass: `notes`, `todos` (and anything left of `hackernews-spa`). 5. `todos-server` reshape (V1–V4 first). Target decided 2026-09-29: the diff --git a/examples/hackernews-spa/README.md b/examples/hackernews-spa/README.md index eaec23fdc..142592816 100644 --- a/examples/hackernews-spa/README.md +++ b/examples/hackernews-spa/README.md @@ -2,7 +2,8 @@ A real HackerNews client built the conventional way: server functions return JSON, client components render everything, and the document ships standard -hydration data. +hydration data. It sits in the client-owned, request/response corner of the +examples, beside `todos`. It is the comparison twin of [../hackernews](../hackernews) — the *same application*, with the same routes, the same markup, and the same data layer, @@ -31,16 +32,20 @@ carries content once and ships only the components that own state. ## How it's wired -- [src/lib/hn.ts](./src/lib/hn.ts) — the data source, server-only. Live HN API, - except story `30186326` ("Facebook loses users for the first time", 1,406 - comments, 14 levels deep), which is served from a capture so the big thread - is deterministic. -- [src/lib/api.ts](./src/lib/api.ts) — `query()` wrappers so the router can - preload on hover and dedupe the call the route then makes. +- [src/routes/](./src/routes) — one file per screen: its server function + inside `query` (so the router can preload on hover and dedupe the call the + route then makes, and the result lands in the hydration data), and the route + component that renders it. Only the default component and `preload` are + exported. The twin's route files hold the same `getStory`, returning markup + instead of data. +- [src/server/hn.ts](./src/server/hn.ts) — the data source, identical to the + twin's. Live HN API, except story `30186326` ("Facebook loses users for the + first time", 1,406 comments, 14 levels deep), which is served from a capture + so the big thread is deterministic. It begins `import "server-only"`, which + fails the build if it is ever imported from client code. - [src/components/](./src/components) — the templates: nav, story, comment, - and the collapse toggle. In the twin, only the toggle exists on the client. -- [src/routes/](./src/routes) — one route component per view, each reading its - query. + and the collapse toggle. The twin has no client components: its markup is + server components, and the toggle is a fill on server markup. - [vite.config.ts](./vite.config.ts) — the turnkey `ssr` object generates the entries and the serving layer, so there is no `entry-server`, `entry-client`, or dev-server script here. `serverFunctions` serves the `/_server` endpoint. diff --git a/examples/hackernews-spa/src/lib/api.ts b/examples/hackernews-spa/src/lib/api.ts deleted file mode 100644 index 83e358cd3..000000000 --- a/examples/hackernews-spa/src/lib/api.ts +++ /dev/null @@ -1,14 +0,0 @@ -// The data the client renders from. `query` wraps the server functions so the -// router can preload on link hover and dedupe the call the route component -// then makes — and so each result is serialized into the hydration payload. -// -// That payload is the point of this baseline: everything below arrives as -// JSON, the client renders every template from it, and the initial document -// therefore carries each story and comment twice — once as the HTML the -// server painted, once as the data that produced it. -import { query } from "@solidjs/router"; -import * as hn from "./hn"; - -export const getStories = query(hn.getStories, "stories"); -export const getStory = query(hn.getStory, "story"); -export const getUser = query(hn.getUser, "user"); diff --git a/examples/hackernews-spa/src/routes/stories.tsx b/examples/hackernews-spa/src/routes/stories.tsx index e9ad2530e..c67f0c477 100644 --- a/examples/hackernews-spa/src/routes/stories.tsx +++ b/examples/hackernews-spa/src/routes/stories.tsx @@ -1,12 +1,16 @@ -import { type RoutePreloadFuncArgs, type RouteSectionProps } from "@solidjs/router"; +import { query, type RoutePreloadFuncArgs, type RouteSectionProps } from "@solidjs/router"; import { For, Show, createMemo } from "solid-js"; import Story from "~/components/story"; -import { getStories } from "~/lib/api"; +import * as hn from "~/server/hn"; import type { StoryTypes } from "~/types"; +const getStories = query(async (type: StoryTypes, page: number) => { + "use server"; + return hn.getStories(type, page); +}, "stories"); + /** `/` and the four named feeds all render this; the path names the feed. */ -export const storyType = (pathname: string): StoryTypes => - (pathname.split("/")[1] || "top") as StoryTypes; +const storyType = (pathname: string): StoryTypes => (pathname.split("/")[1] || "top") as StoryTypes; // The feed routes take no params, so the open `RouteSectionProps` is honest here. export const preload = ({ location }: RoutePreloadFuncArgs) => { diff --git a/examples/hackernews-spa/src/routes/story.tsx b/examples/hackernews-spa/src/routes/story.tsx index f841aff7d..4142af807 100644 --- a/examples/hackernews-spa/src/routes/story.tsx +++ b/examples/hackernews-spa/src/routes/story.tsx @@ -1,12 +1,22 @@ -import { type RouteParams, type RoutePreloadFuncArgs, type RouteProps } from "@solidjs/router"; +import { + query, + type RouteParams, + type RoutePreloadFuncArgs, + type RouteProps +} from "@solidjs/router"; import { For, Show, createMemo } from "solid-js"; import Comment from "~/components/comment"; -import { getStory } from "~/lib/api"; +import * as hn from "~/server/hn"; // The route lives in app.tsx, so the component and preload here name the // pattern they belong to; `params.id` is then `string`, not `string | undefined`. type Path = "/stories/:id"; +const getStory = query(async (id: string) => { + "use server"; + return hn.getStory(id); +}, "story"); + export const preload = ({ params }: RoutePreloadFuncArgs>) => { void getStory(params.id); }; diff --git a/examples/hackernews-spa/src/routes/user.tsx b/examples/hackernews-spa/src/routes/user.tsx index 43dc53527..608caa4af 100644 --- a/examples/hackernews-spa/src/routes/user.tsx +++ b/examples/hackernews-spa/src/routes/user.tsx @@ -1,9 +1,19 @@ -import { type RouteParams, type RoutePreloadFuncArgs, type RouteProps } from "@solidjs/router"; +import { + query, + type RouteParams, + type RoutePreloadFuncArgs, + type RouteProps +} from "@solidjs/router"; import { Show, createMemo } from "solid-js"; -import { getUser } from "~/lib/api"; +import * as hn from "~/server/hn"; type Path = "/users/:id"; +const getUser = query(async (id: string) => { + "use server"; + return hn.getUser(id); +}, "user"); + export const preload = ({ params }: RoutePreloadFuncArgs>) => { void getUser(params.id); }; diff --git a/examples/hackernews/src/lib/hn.ts b/examples/hackernews-spa/src/server/hn.ts similarity index 85% rename from examples/hackernews/src/lib/hn.ts rename to examples/hackernews-spa/src/server/hn.ts index ea3f130fb..f07ca5a28 100644 --- a/examples/hackernews/src/lib/hn.ts +++ b/examples/hackernews-spa/src/server/hn.ts @@ -1,7 +1,8 @@ -"use server"; -// The HN data source, server-only. Module-level directive: the client build -// replaces this whole module with reference proxies, so the cached thread -// below never reaches the browser — important, because it is 600KB. +import "server-only"; +// The HN data source. Only `"use server"` bodies import it, and the client +// build replaces those with references, so the cached thread below (600KB) +// never reaches the browser; the `server-only` marker fails the build if +// this module is ever imported from client code. import type { StoryDefinition, StoryTypes, UserDefinition } from "~/types"; import cachedStory from "./story-30186326.json"; diff --git a/examples/hackernews-spa/src/lib/story-30186326.json b/examples/hackernews-spa/src/server/story-30186326.json similarity index 100% rename from examples/hackernews-spa/src/lib/story-30186326.json rename to examples/hackernews-spa/src/server/story-30186326.json diff --git a/examples/hackernews-spa/src/types.ts b/examples/hackernews-spa/src/types.ts index 7e2ceb863..4239674c4 100644 --- a/examples/hackernews-spa/src/types.ts +++ b/examples/hackernews-spa/src/types.ts @@ -1,4 +1,5 @@ export interface CommentDefinition { + id: number; user: string; time_ago: string; content: string; @@ -6,7 +7,7 @@ export interface CommentDefinition { } export interface StoryDefinition { - id: string; + id: number; points: string; url: string; title: string; diff --git a/examples/hackernews-spa/src/vite-env.d.ts b/examples/hackernews-spa/src/vite-env.d.ts index 11f02fe2a..c3957e75c 100644 --- a/examples/hackernews-spa/src/vite-env.d.ts +++ b/examples/hackernews-spa/src/vite-env.d.ts @@ -1 +1,2 @@ /// +/// diff --git a/examples/hackernews/README.md b/examples/hackernews/README.md index a4a346dd5..38b5a522d 100644 --- a/examples/hackernews/README.md +++ b/examples/hackernews/README.md @@ -1,8 +1,10 @@ # HackerNews — Solid Server Components -A real HackerNews client built with **Solid Server Components**: the story -lists, threads, and user pages are rendered on the server and arrive as HTML, -while the browser gets the router and the one component that owns state. +A real HackerNews client where the server owns the markup: story lists, +threads, and user pages are server components that arrive as HTML, and the +browser gets the router and the few decisions only the client can make. It is +the reads half of the server-owned, request/response corner of the examples +(`todos-server` is the writes half). Its twin, [../hackernews-spa](../hackernews-spa), is the *same application* — same routes, same markup, same data layer — built the conventional way, with @@ -14,26 +16,69 @@ pnpm dev # http://localhost:3004 pnpm build && pnpm start # http://localhost:3004 ``` +## Comment collapse: client state on server markup + +The thread is server markup at any depth, and collapsing a comment's replies is +client state. The server component calls a **binding slot** for each comment +that has replies and puts the result's properties at positions on its own +elements ([src/routes/story.tsx](./src/routes/story.tsx)): + +```tsx +const t = c.comments.length ? props.toggle({ $key: c.id }) : null; +// … + +
    + {c.comments.map(reply => )} +
+``` + +The client fills the slot in the same file. The fill runs once per comment, like +a component body — this is the SPA twin's `Toggle` without its markup: + +```tsx + { + const [open, setOpen] = createSignal(true); + return { + get open() { return open(); }, + get label() { return open() ? "[-]" : "[+] comments collapsed"; }, + get display() { return open() ? "block" : "none"; }, + onToggle: () => setOpen(o => !o) + }; + }} +/> +``` + +There are no client components. The 1,406-comment thread has 652 comments with +replies: 652 fills, each owning a class name, a click handler, a text node and +a style property on elements the server rendered. The replies inside are server +markup again, so a subtree streams as HTML once at any depth. The state never +appears in a request, and `$key` keeps it on its comment across refetches: a +refetched thread morphs around the positions the client owns. + ## What a server component is A `"use server"` function that **returns a function** is a server component. The function's arguments are the server's inputs; the returned component's -props are client positions — holes the client fills, which never travel to the -server. From [src/lib/views.tsx](./src/lib/views.tsx): +props are the slots the client fills, which never travel to the server. Each +route file holds its screen's server component inside the router's `query`, +which gives the call cache identity and preloading: ```tsx -export async function storyView(id: string) { - const story = await getStory(id); - return (props: { toggle: Slot }) =>
…
; -} +const getStory = query(async (id: string) => { + "use server"; + const story = await hn.getStory(id); + return (props: { toggle: ToggleSlot }) =>
…
; +}, "story"); ``` On the client side there is no server-component API at all. `dynamic()` over -the call is the entire surface ([src/routes/story.tsx](./src/routes/story.tsx)): +the call is the entire surface: ```tsx -const View = dynamic(() => storyView(props.params.id)); -return {p.children}} />; +const View = dynamic(() => getStory(props.params.id)); ``` The source is tracked, so navigating to another story re-calls it and the @@ -47,21 +92,13 @@ render to feed. Compare with the same view in the SPA twin, where each comment is present twice: once as the HTML the server painted, and again as the JSON that produced it. -**The single client component in a 1,406-comment thread.** -[src/components/toggle.tsx](./src/components/toggle.tsx) owns collapse state -and nothing else. The server calls `props.toggle` for each comment that has -replies, and the replies inside it are server markup again — so a subtree -streams as HTML once at any depth, with client behavior interleaved. Collapse -state is client state: it never appears in a request, and `$key` keeps it -attached to its comment across refetches. - **The client bundle.** No story, comment, or list templates reach the browser: -grep `dist/client/` for `item-view-comments-header` and it isn't there, because -[src/lib/views.tsx](./src/lib/views.tsx) is a `"use server"` module and the -client build strips it. What *is* there is the router, the loading fallbacks, -and `Toggle` — which is why `comment-children` still appears, since the client -owns the replies list it wraps. (The 1,406-comment capture stays on the server -in both apps; `hn.ts` is server-only either way.) +grep the client JavaScript (`dist/client/assets/*.js`) for +`item-view-comments-header` or `comment-children` and neither is there. The +server component bodies, `Comment`, and the `hn` data layer they use are +removed from the client build; what ships is the router, the loading fallbacks, +and the `toggle` fill. (The 1,406-comment capture stays on the server in both +apps.) **The nav is a server component too.** It is static chrome with no reactive input, so it renders inline at t=0, the client adopts it, and navigation @@ -73,18 +110,20 @@ JSON, and the boundary morphs as they arrive. ## How it's wired -- [src/lib/hn.ts](./src/lib/hn.ts) — the data source, server-only. Live HN API, - except story `30186326` ("Facebook loses users for the first time", 1,406 - comments, 14 levels deep), which is served from a capture so the big thread - is deterministic. -- [src/lib/views.tsx](./src/lib/views.tsx) — the server components. Every view - here renders the exact markup its SPA counterpart renders in the browser. -- [src/routes/](./src/routes) — one `dynamic()` call each, no templates. -- [src/app.tsx](./src/app.tsx) — the router, the loading boundaries, and - nothing else. There are no story, comment, or list templates on this side. +- [src/routes/](./src/routes) — one file per screen: its server component + inside `query`, the server-only helpers it renders (the recursive `Comment`), + the slot's type, and the route component with its fill. Only the default + component and `preload` are exported. +- [src/server/hn.ts](./src/server/hn.ts) — the data source. Live HN API, except + story `30186326` ("Facebook loses users for the first time", 1,406 comments, + 14 levels deep), which is served from a capture so the big thread is + deterministic. It begins `import "server-only"`, which fails the build if it + is ever imported from client code. +- [src/app.tsx](./src/app.tsx) — the router, the loading boundaries, and the + nav's server component. - [vite.config.ts](./vite.config.ts) — identical to the SPA twin's but for one flag: `serverFunctions: { components: true }`. That flag is the entire wiring - difference between the two apps. The turnkey `ssr` object generates the + difference between the two apps. The turnkey `start` object generates the entries, the render plugin, and the document bootstrap, so nothing in `src/` imports the frames runtime. - [server.js](./server.js) — a plain node server: static assets, the SSR diff --git a/examples/hackernews/src/app.tsx b/examples/hackernews/src/app.tsx index 44c5a0152..2bcb787d1 100644 --- a/examples/hackernews/src/app.tsx +++ b/examples/hackernews/src/app.tsx @@ -2,21 +2,52 @@ // ../hackernews-spa/src/app.tsx: same router, same routes, same boundary. What // is missing here is the app itself — there are no story, comment, or list // templates on this side, because that markup is returned by server components -// and arrives as HTML. All that ships is the router, the boundary, and the one -// component that owns state (Toggle). +// and arrives as HTML. All that ships is the router, the boundaries, and the +// thread's collapse fill (routes/story.tsx); there are no client components. // // Note there is no server-component API in this file. `dynamic()` over a -// `"use server"` call is the entire client surface (see the routes); the -// transport install lives in the generated entry. +// `"use server"` call is the entire client surface; the transport install +// lives in the generated entry. import { createRouter, defineRoute } from "@solidjs/router"; import { Loading } from "solid-js"; import { dynamic } from "@solidjs/web"; -import { navView } from "~/lib/views"; import Stories, { preload as preloadStories } from "~/routes/stories"; import Story, { preload as preloadStory } from "~/routes/story"; import User, { preload as preloadUser } from "~/routes/user"; import "./app.css"; +// The nav is a server component too. It is static chrome, so there is no +// reason for its markup to ship as client templates at all — and with no +// reactive input it is never refetched: it renders inline at t=0, the client +// adopts it, and navigation leaves it alone. +async function getNav() { + "use server"; + return () => ( +
+ +
+ ); +} + // `defineRoute` types each route's component and preload from its own `path`, // so the `:id` routes read `params.id` as `string` rather than // `string | undefined`. The preloads make link hover/focus fetch the route's @@ -35,11 +66,7 @@ const Router = createRouter({ }); export default function App() { - // The nav is a server component too. It is static chrome, so there is no - // reason for its markup to ship as client templates at all — and with no - // reactive input it is never refetched: it renders inline at t=0, the client - // adopts it, and navigation leaves it alone. - const Nav = dynamic(() => navView()); + const Nav = dynamic(() => getNav()); return ( {props => ( diff --git a/examples/hackernews/src/components/toggle.tsx b/examples/hackernews/src/components/toggle.tsx deleted file mode 100644 index 32375785e..000000000 --- a/examples/hackernews/src/components/toggle.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { createSignal } from "solid-js"; - -export default function Toggle(props: { children: any }) { - const [open, setOpen] = createSignal(true); - - return ( - <> - -
    - {props.children} -
- - ); -} diff --git a/examples/hackernews/src/lib/api.ts b/examples/hackernews/src/lib/api.ts deleted file mode 100644 index d85d8b909..000000000 --- a/examples/hackernews/src/lib/api.ts +++ /dev/null @@ -1,12 +0,0 @@ -// The client-facing data surface, mirroring ../hackernews-spa/src/lib/api.ts: -// the server components wrapped in the router's `query` — cache identity and -// preload participation. Hovering a link preloads the route's server -// component and the navigation reads the same cache entry, so one request -// serves both. The nav view is chrome, not data (never refetched), so it -// stays on `dynamic()` alone in app.tsx. -import { query } from "@solidjs/router"; -import { storiesView, storyView, userView } from "./views"; - -export const getStories = query(storiesView, "stories"); -export const getStory = query(storyView, "story"); -export const getUser = query(userView, "user"); diff --git a/examples/hackernews/src/lib/views.tsx b/examples/hackernews/src/lib/views.tsx deleted file mode 100644 index e5195a39b..000000000 --- a/examples/hackernews/src/lib/views.tsx +++ /dev/null @@ -1,184 +0,0 @@ -"use server"; -// The server components. A `"use server"` function that RETURNS a function is -// a server component: the function's ARGUMENTS are server inputs (ids, feed, -// page), and the returned component's PROPS are client positions — holes the -// client fills that never travel to the server. -// -// Every view below renders the exact markup its ../hackernews-spa counterpart -// renders in the browser. The difference is where it exists: here it is HTML -// produced once on the server, and the data behind it (a 1,406-comment thread) -// never crosses the wire. In the SPA twin that same thread arrives twice — as -// the HTML the server painted and again as the JSON that produced it. -import type { ComponentProps } from "solid-js"; -import type { Slot } from "@solidjs/web/frames"; -import { getStories, getStory, getUser } from "./hn"; -import type Toggle from "~/components/toggle"; -import type { CommentDefinition, StoryTypes } from "~/types"; - -/** - * A client position: the server renders it where client-owned markup belongs - * and the client puts its own component there. `Slot` takes the client - * component's own props, so the hole is described with the same type that - * fills it rather than a restatement of its shape. - */ -type ToggleSlot = Slot>; - -export async function navView() { - return () => ( -
- -
- ); -} - -export async function storiesView(type: StoryTypes, page: number) { - const stories = await getStories(type, page); - return () => ( -
-
- {page > 1 ? ( - - {"<"} prev - - ) : ( - - {"<"} prev - - )} - page {page} - {stories.length >= 29 ? ( - - more {">"} - - ) : ( - - more {">"} - - )} -
-
- {stories.map(story => ( -
  • - {story.points} - - {story.url ? ( - <> - - {story.title} - - ({story.domain}) - - ) : ( - {story.title} - )} - -
    - - {story.type !== "job" ? ( - <> - by {story.user} {story.time_ago} |{" "} - - {story.comments_count ? `${story.comments_count} comments` : "discuss"} - - - ) : ( - {story.time_ago} - )} - - {story.type !== "link" ? ( - <> - {" "} - {story.type} - - ) : null} -
  • - ))} -
    -
    - ); -} - -export async function storyView(id: string) { - const story = await getStory(id); - - /** - * Recursive single-copy composition. A comment's own byline and text are - * plain server markup; only the collapse control is client-owned, so the - * replies list is handed to the `toggle` slot — the client renders the SPA's - * Toggle there and the nested comments inside it are server markup again. - * Each subtree therefore streams as HTML exactly once, at any depth. - */ - const comment = (c: CommentDefinition, toggle: ToggleSlot): unknown => ( -
  • -
    - {c.user} {c.time_ago} ago -
    -
    - {c.comments.length - ? toggle({ children: c.comments.map(child => comment(child, toggle)) }) - : null} -
  • - ); - - return (props: { toggle: ToggleSlot }) => ( -
    -
    - -

    {story.title}

    -
    - {story.domain ? ({story.domain}) : null} -

    - {story.points} points | by {story.user}{" "} - {story.time_ago} ago -

    -
    -
    -

    - {story.comments_count ? story.comments_count + " comments" : "No comments yet."} -

    -
      {story.comments.map(c => comment(c, props.toggle))}
    -
    -
    - ); -} - -export async function userView(id: string) { - const user = await getUser(id); - return () => ( -
    -

    User : {user.id}

    -
      -
    • - Created: {user.created} -
    • -
    • - Karma: {user.karma} -
    • - {user.about ?
    • : null} -
    - -
    - ); -} diff --git a/examples/hackernews/src/routes/stories.tsx b/examples/hackernews/src/routes/stories.tsx index 7f811ed04..f3eb33815 100644 --- a/examples/hackernews/src/routes/stories.tsx +++ b/examples/hackernews/src/routes/stories.tsx @@ -1,11 +1,78 @@ -import { type RoutePreloadFuncArgs, type RouteSectionProps } from "@solidjs/router"; +import { query, type RoutePreloadFuncArgs, type RouteSectionProps } from "@solidjs/router"; import { dynamic } from "@solidjs/web"; -import { getStories } from "~/lib/api"; +import * as hn from "~/server/hn"; import type { StoryTypes } from "~/types"; +const getStories = query(async (type: StoryTypes, page: number) => { + "use server"; + const stories = await hn.getStories(type, page); + return () => ( +
    +
    + {page > 1 ? ( + + {"<"} prev + + ) : ( + + {"<"} prev + + )} + page {page} + {stories.length >= 29 ? ( + + more {">"} + + ) : ( + + more {">"} + + )} +
    +
    + {stories.map(story => ( +
  • + {story.points} + + {story.url ? ( + <> + + {story.title} + + ({story.domain}) + + ) : ( + {story.title} + )} + +
    + + {story.type !== "job" ? ( + <> + by {story.user} {story.time_ago} |{" "} + + {story.comments_count ? `${story.comments_count} comments` : "discuss"} + + + ) : ( + {story.time_ago} + )} + + {story.type !== "link" ? ( + <> + {" "} + {story.type} + + ) : null} +
  • + ))} +
    +
    + ); +}, "stories"); + /** `/` and the four named feeds all render this; the path names the feed. */ -export const storyType = (pathname: string): StoryTypes => - (pathname.split("/")[1] || "top") as StoryTypes; +const storyType = (pathname: string): StoryTypes => (pathname.split("/")[1] || "top") as StoryTypes; // The feed routes take no params, so the open `RoutePreloadFuncArgs` is honest here. export const preload = ({ location }: RoutePreloadFuncArgs) => { diff --git a/examples/hackernews/src/routes/story.tsx b/examples/hackernews/src/routes/story.tsx index 94c3fd478..cc433e5fa 100644 --- a/examples/hackernews/src/routes/story.tsx +++ b/examples/hackernews/src/routes/story.tsx @@ -1,21 +1,109 @@ -import { type RouteParams, type RoutePreloadFuncArgs, type RouteProps } from "@solidjs/router"; +import { + query, + type RouteParams, + type RoutePreloadFuncArgs, + type RouteProps +} from "@solidjs/router"; +import { createSignal } from "solid-js"; import { dynamic } from "@solidjs/web"; -import Toggle from "~/components/toggle"; -import { getStory } from "~/lib/api"; +import type { BindingSlot } from "@solidjs/web/frames"; +import * as hn from "~/server/hn"; +import type { CommentDefinition } from "~/types"; // The pattern witness types `params.id` as `string` — this component is // declared away from its route, so it names the pattern it belongs to. type Path = "/stories/:id"; +/** What the client decides about one comment's replies. */ +interface Toggle { + open: boolean; + label: string; + display: "block" | "none"; + onToggle: () => void; +} +type ToggleSlot = BindingSlot<{}, Toggle>; + +const getStory = query(async (id: string) => { + "use server"; + const story = await hn.getStory(id); + return (props: { toggle: ToggleSlot }) => ( +
    +
    + +

    {story.title}

    +
    + {story.domain ? ({story.domain}) : null} +

    + {story.points} points | by {story.user}{" "} + {story.time_ago} ago +

    +
    +
    +

    + {story.comments_count ? story.comments_count + " comments" : "No comments yet."} +

    +
      + {story.comments.map(comment => ( + + ))} +
    +
    +
    + ); +}, "story"); + +/** + * Recursive, and entirely server markup: the client never sees the tree, + * only one toggle per comment that has replies. + */ +function Comment(props: { comment: CommentDefinition; toggle: ToggleSlot }) { + const c = props.comment; + const t = c.comments.length ? props.toggle({ $key: c.id }) : null; + return ( +
  • +
    + {c.user} {c.time_ago} ago +
    +
    + {t ? ( + <> +
    + {t.label} +
    +
      + {c.comments.map(reply => ( + + ))} +
    + + ) : null} +
  • + ); +} + export const preload = ({ params }: RoutePreloadFuncArgs>) => { void getStory(params.id); }; export default function Story(props: RouteProps) { const View = dynamic(() => getStory(props.params.id)); - // The one client-owned piece of a thread: the server fills this slot per - // comment that has replies, and the replies themselves arrive as server - // markup inside the Toggle's list. Collapse state is client state — it never - // appears in a request. - return {p.children}} />; + return ( + { + const [open, setOpen] = createSignal(true); + return { + get open() { + return open(); + }, + get label() { + return open() ? "[-]" : "[+] comments collapsed"; + }, + get display() { + return open() ? "block" : "none"; + }, + onToggle: () => setOpen(o => !o) + }; + }} + /> + ); } diff --git a/examples/hackernews/src/routes/user.tsx b/examples/hackernews/src/routes/user.tsx index 3e75e36dd..10330d251 100644 --- a/examples/hackernews/src/routes/user.tsx +++ b/examples/hackernews/src/routes/user.tsx @@ -1,9 +1,37 @@ -import { type RouteParams, type RoutePreloadFuncArgs, type RouteProps } from "@solidjs/router"; +import { + query, + type RouteParams, + type RoutePreloadFuncArgs, + type RouteProps +} from "@solidjs/router"; import { dynamic } from "@solidjs/web"; -import { getUser } from "~/lib/api"; +import * as hn from "~/server/hn"; type Path = "/users/:id"; +const getUser = query(async (id: string) => { + "use server"; + const user = await hn.getUser(id); + return () => ( +
    +

    User : {user.id}

    +
      +
    • + Created: {user.created} +
    • +
    • + Karma: {user.karma} +
    • + {user.about ?
    • : null} +
    + +
    + ); +}, "user"); + export const preload = ({ params }: RoutePreloadFuncArgs>) => { void getUser(params.id); }; diff --git a/examples/hackernews-spa/src/lib/hn.ts b/examples/hackernews/src/server/hn.ts similarity index 85% rename from examples/hackernews-spa/src/lib/hn.ts rename to examples/hackernews/src/server/hn.ts index ea3f130fb..f07ca5a28 100644 --- a/examples/hackernews-spa/src/lib/hn.ts +++ b/examples/hackernews/src/server/hn.ts @@ -1,7 +1,8 @@ -"use server"; -// The HN data source, server-only. Module-level directive: the client build -// replaces this whole module with reference proxies, so the cached thread -// below never reaches the browser — important, because it is 600KB. +import "server-only"; +// The HN data source. Only `"use server"` bodies import it, and the client +// build replaces those with references, so the cached thread below (600KB) +// never reaches the browser; the `server-only` marker fails the build if +// this module is ever imported from client code. import type { StoryDefinition, StoryTypes, UserDefinition } from "~/types"; import cachedStory from "./story-30186326.json"; diff --git a/examples/hackernews/src/lib/story-30186326.json b/examples/hackernews/src/server/story-30186326.json similarity index 100% rename from examples/hackernews/src/lib/story-30186326.json rename to examples/hackernews/src/server/story-30186326.json diff --git a/examples/hackernews/src/types.ts b/examples/hackernews/src/types.ts index 7e2ceb863..4239674c4 100644 --- a/examples/hackernews/src/types.ts +++ b/examples/hackernews/src/types.ts @@ -1,4 +1,5 @@ export interface CommentDefinition { + id: number; user: string; time_ago: string; content: string; @@ -6,7 +7,7 @@ export interface CommentDefinition { } export interface StoryDefinition { - id: string; + id: number; points: string; url: string; title: string; diff --git a/examples/hackernews/src/vite-env.d.ts b/examples/hackernews/src/vite-env.d.ts index 11f02fe2a..c3957e75c 100644 --- a/examples/hackernews/src/vite-env.d.ts +++ b/examples/hackernews/src/vite-env.d.ts @@ -1 +1,2 @@ /// +/// diff --git a/packages/web/test/server/frame-binding-slots.spec.tsx b/packages/web/test/server/frame-binding-slots.spec.tsx index a5760faa3..f27a7696b 100644 --- a/packages/web/test/server/frame-binding-slots.spec.tsx +++ b/packages/web/test/server/frame-binding-slots.spec.tsx @@ -235,6 +235,23 @@ describe("binding slots — stream face", () => { ); }); + it("a class ARRAY binds its object entries' names as the object form does; its strings stay inline", async () => { + const ServerComp = (props: any) => { + const t = props.toggle({ $key: 7 }); + return ( +
    + {t.label} +
    + ); + }; + const chunks = await collect(renderServerComponent(ServerComp, { frame: { id: "ds1a" } })); + const html = plain(chunks.find(c => c.type === "html").html); + expect(html).toContain( + '
    ' + ); + expect(findings()).toEqual([]); + }); + it("keys and class names percent-encode onto the marker alphabet", async () => { const ServerComp = (props: any) => { const row = props.row({ id: 1 }); @@ -793,6 +810,42 @@ describe("binding slots — document face (t=0)", () => { expect(findings()).toEqual([]); }); + it("a class ARRAY writes its strings and its bound names' t=0 values into one class attribute", async () => { + const ServerComp = (props: any) => ( +
      + {[1, 2].map(id => { + const t = props.toggle({ $key: id }); + return ( +
    • +
      + {t.label} +
      +
    • + ); + })} +
    + ); + const Inline = frameTransformDirectResult(ServerComp, { id: "dsd1a" }) as any; + let calls = 0; + const html = plain( + await document(() => + Inline({ + toggle: () => { + const open = calls++ === 0; + return { open, label: open ? "[-]" : "[+] comments collapsed", onToggle: () => {} }; + } + }) + ) + ); + expect(html).toContain( + '' + ); + expect(html).toContain( + '' + ); + expect(findings()).toEqual([]); + }); + it("a `$key`ed call in a component prop runs the fill ONCE and emits ONE record at t=0", async () => { const ServerComp = (props: any) => (
      From 689b9ff48e909822e7374c31f694ba3c7eaba6bc Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 29 Sep 2026 22:36:30 -0700 Subject: [PATCH 2/6] examples: hackernews server routes, nav outside Loading, router next.31 The feeds and the user page have no client half, so they become server routes: `serverRouteComponent(query(...))`, with the router making the call from the match on navigation and on link hover, so they need no route component and no `preload`. The feeds are one `/:type?` route filtered to the five feed names, with `page` read through a hand-written search schema. The story route keeps its component because of the collapse fill. The nav leaves the `Loading` boundary, since it does no I/O and the shell can wait on it. The SPA twin uses the same route pattern and filter, and parses `?page` itself. Router 2.0.0-next.29 sent a schema-less server route's args as `{ params, search: undefined }`. The JSON argument check rejects that, so client navigation to the user page silently never happened. next.30 fixed it (#615), so every example that uses the router moves to next.31. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- documentation/plans/examples-grid-plan.md | 11 +++++ examples/hackernews-spa/README.md | 3 +- examples/hackernews-spa/package.json | 2 +- examples/hackernews-spa/src/app.tsx | 7 +-- .../hackernews-spa/src/routes/stories.tsx | 28 +++++++---- examples/hackernews/README.md | 34 ++++++++----- examples/hackernews/package.json | 2 +- examples/hackernews/src/app.tsx | 36 +++++++------- examples/hackernews/src/routes/stories.tsx | 48 ++++++++++--------- examples/hackernews/src/routes/user.tsx | 23 ++++----- examples/notes/package.json | 2 +- examples/room/package.json | 2 +- pnpm-lock.yaml | 22 ++++----- 13 files changed, 126 insertions(+), 94 deletions(-) diff --git a/documentation/plans/examples-grid-plan.md b/documentation/plans/examples-grid-plan.md index 183be444e..e2d3d7693 100644 --- a/documentation/plans/examples-grid-plan.md +++ b/documentation/plans/examples-grid-plan.md @@ -169,6 +169,17 @@ fills. In the browser the collapsed state keeps its element and text node through a `revalidate("story")` refetch. The bundle check greps the client JavaScript, since `app.css` carries both class names. +Follow-up in review: the feeds and the user page are server routes +(`serverRouteComponent`, router 2.0.0-next.31), so they have no route +component and no `preload`. The feeds are one `/:type?` route filtered to +the five feed names, with `page` from a hand-written search schema; the SPA +twin mirrors the pattern and filter and parses `?page` itself. The story +route keeps its component because of the fill. The nav is outside `Loading`, +since it does no I/O. Router next.29 broke client navigation to the user +page: it sent a schema-less route's args as `{ params, search: undefined }`, +which the JSON argument check rejects, and nothing was logged. next.30 +(#615) fixed that, so every example that uses the router moved to next.31. + ### `hackernews-spa` — top-left. KEEP; layout only. Built 2026-09-29 The twin; exists only as the comparison. README names the coordinate. Takes diff --git a/examples/hackernews-spa/README.md b/examples/hackernews-spa/README.md index 142592816..d29799b9a 100644 --- a/examples/hackernews-spa/README.md +++ b/examples/hackernews-spa/README.md @@ -37,7 +37,8 @@ carries content once and ships only the components that own state. route then makes, and the result lands in the hydration data), and the route component that renders it. Only the default component and `preload` are exported. The twin's route files hold the same `getStory`, returning markup - instead of data. + instead of data; its feeds and user page have no route component at all, + because there the router makes the call. - [src/server/hn.ts](./src/server/hn.ts) — the data source, identical to the twin's. Live HN API, except story `30186326` ("Facebook loses users for the first time", 1,406 comments, 14 levels deep), which is served from a capture diff --git a/examples/hackernews-spa/package.json b/examples/hackernews-spa/package.json index a4796bf25..f20a3ce39 100644 --- a/examples/hackernews-spa/package.json +++ b/examples/hackernews-spa/package.json @@ -13,7 +13,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@solidjs/router": "2.0.0-next.29", + "@solidjs/router": "2.0.0-next.31", "@solidjs/web": "workspace:*", "solid-js": "workspace:*" }, diff --git a/examples/hackernews-spa/src/app.tsx b/examples/hackernews-spa/src/app.tsx index c83cab353..72ce04ea5 100644 --- a/examples/hackernews-spa/src/app.tsx +++ b/examples/hackernews-spa/src/app.tsx @@ -13,15 +13,16 @@ import User, { preload as preloadUser } from "~/routes/user"; import "./app.css"; // Explicit route tree rather than the file routes a metaframework provides: -// this example is plain Vite. The feed paths are enumerated instead of a -// splat so the typed path proxy stays useful. +// this example is plain Vite. The same table as the server-components twin: +// the feed is an optional param the filter limits to the five feed names. // `defineRoute` types each route's component and preload from its own `path`, // so the `:id` routes read `params.id` as `string` rather than // `string | undefined`. const Router = createRouter({ routes: [ defineRoute({ - path: ["/", "/top", "/new", "/show", "/ask", "/job"], + path: "/:type?", + matchFilters: { type: ["top", "new", "show", "ask", "job"] }, component: Stories, preload: preloadStories }), diff --git a/examples/hackernews-spa/src/routes/stories.tsx b/examples/hackernews-spa/src/routes/stories.tsx index c67f0c477..dae5da6bb 100644 --- a/examples/hackernews-spa/src/routes/stories.tsx +++ b/examples/hackernews-spa/src/routes/stories.tsx @@ -1,25 +1,35 @@ -import { query, type RoutePreloadFuncArgs, type RouteSectionProps } from "@solidjs/router"; +import { + query, + type RouteParams, + type RoutePreloadFuncArgs, + type RouteProps, + type SearchParams +} from "@solidjs/router"; import { For, Show, createMemo } from "solid-js"; import Story from "~/components/story"; import * as hn from "~/server/hn"; import type { StoryTypes } from "~/types"; +// The route lives in app.tsx, so the component and preload here name the +// pattern they belong to. +type Path = "/:type?"; + const getStories = query(async (type: StoryTypes, page: number) => { "use server"; return hn.getStories(type, page); }, "stories"); -/** `/` and the four named feeds all render this; the path names the feed. */ -const storyType = (pathname: string): StoryTypes => (pathname.split("/")[1] || "top") as StoryTypes; +/** `/` is the top feed; the route's filter admits only the five feed names. */ +const storyType = (params: RouteParams) => (params.type || "top") as StoryTypes; +const pageOf = (query: SearchParams) => Number(query.page) || 1; -// The feed routes take no params, so the open `RouteSectionProps` is honest here. -export const preload = ({ location }: RoutePreloadFuncArgs) => { - void getStories(storyType(location.pathname), Number(location.query.page) || 1); +export const preload = ({ params, location }: RoutePreloadFuncArgs>) => { + void getStories(storyType(params), pageOf(location.query)); }; -export default function Stories(props: RouteSectionProps) { - const page = () => Number(props.location.query.page) || 1; - const type = () => storyType(props.location.pathname); +export default function Stories(props: RouteProps) { + const page = () => pageOf(props.location.query); + const type = () => storyType(props.params); const stories = createMemo(() => getStories(type(), page())); return ( diff --git a/examples/hackernews/README.md b/examples/hackernews/README.md index 38b5a522d..2e238cf7b 100644 --- a/examples/hackernews/README.md +++ b/examples/hackernews/README.md @@ -74,15 +74,26 @@ const getStory = query(async (id: string) => { }, "story"); ``` -On the client side there is no server-component API at all. `dynamic()` over -the call is the entire surface: +On the client side the only question is how the call gets made. A screen with +nothing for the client to fill — the feeds, the user page — is a server route: +the router makes the call from the match (the route's params, and the feeds' +`page` through the route's search schema), on navigation and on link hover +alike, so there is no route component and no `preload` to write: + +```tsx +export default serverRouteComponent(getUser); +``` + +The thread has a client half, the collapse fill, so it is an ordinary route +component, and `dynamic()` over the call is its entire surface: ```tsx const View = dynamic(() => getStory(props.params.id)); ``` -The source is tracked, so navigating to another story re-calls it and the -response morphs that boundary in place — no remount, no fallback re-flash. +Either way the call is tracked, so navigating to another story or feed re-calls +it and the response morphs that boundary in place — no remount, no fallback +re-flash. ## What to look at @@ -96,7 +107,7 @@ that produced it. grep the client JavaScript (`dist/client/assets/*.js`) for `item-view-comments-header` or `comment-children` and neither is there. The server component bodies, `Comment`, and the `hn` data layer they use are -removed from the client build; what ships is the router, the loading fallbacks, +removed from the client build; what ships is the router, the loading fallback, and the `toggle` fill. (The 1,406-comment capture stays on the server in both apps.) @@ -110,17 +121,18 @@ JSON, and the boundary morphs as they arrive. ## How it's wired -- [src/routes/](./src/routes) — one file per screen: its server component - inside `query`, the server-only helpers it renders (the recursive `Comment`), - the slot's type, and the route component with its fill. Only the default - component and `preload` are exported. +- [src/routes/](./src/routes) — one file per screen, each holding its server + component inside `query`. The feeds and the user page export it as a server + route (the feeds also export their `?page` search schema); the thread also + holds the server-only recursive `Comment`, the slot's type, and the route + component with its fill, and exports its `preload`. - [src/server/hn.ts](./src/server/hn.ts) — the data source. Live HN API, except story `30186326` ("Facebook loses users for the first time", 1,406 comments, 14 levels deep), which is served from a capture so the big thread is deterministic. It begins `import "server-only"`, which fails the build if it is ever imported from client code. -- [src/app.tsx](./src/app.tsx) — the router, the loading boundaries, and the - nav's server component. +- [src/app.tsx](./src/app.tsx) — the route table (the same as the SPA twin's), + the loading boundary, and the nav's server component. - [vite.config.ts](./vite.config.ts) — identical to the SPA twin's but for one flag: `serverFunctions: { components: true }`. That flag is the entire wiring difference between the two apps. The turnkey `start` object generates the diff --git a/examples/hackernews/package.json b/examples/hackernews/package.json index 8ea8893e4..17b12d6ee 100644 --- a/examples/hackernews/package.json +++ b/examples/hackernews/package.json @@ -13,7 +13,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@solidjs/router": "2.0.0-next.29", + "@solidjs/router": "2.0.0-next.31", "@solidjs/web": "workspace:*", "solid-js": "workspace:*" }, diff --git a/examples/hackernews/src/app.tsx b/examples/hackernews/src/app.tsx index 2bcb787d1..dbbbad304 100644 --- a/examples/hackernews/src/app.tsx +++ b/examples/hackernews/src/app.tsx @@ -5,21 +5,22 @@ // and arrives as HTML. All that ships is the router, the boundaries, and the // thread's collapse fill (routes/story.tsx); there are no client components. // -// Note there is no server-component API in this file. `dynamic()` over a -// `"use server"` call is the entire client surface; the transport install -// lives in the generated entry. +// Note there is no server-component API in this file. A `"use server"` call +// mounts as a route (`serverRouteComponent`) or through `dynamic()`; the +// transport install lives in the generated entry. import { createRouter, defineRoute } from "@solidjs/router"; import { Loading } from "solid-js"; import { dynamic } from "@solidjs/web"; -import Stories, { preload as preloadStories } from "~/routes/stories"; +import Stories, { searchSchema } from "~/routes/stories"; import Story, { preload as preloadStory } from "~/routes/story"; -import User, { preload as preloadUser } from "~/routes/user"; +import User from "~/routes/user"; import "./app.css"; // The nav is a server component too. It is static chrome, so there is no // reason for its markup to ship as client templates at all — and with no // reactive input it is never refetched: it renders inline at t=0, the client -// adopts it, and navigation leaves it alone. +// adopts it, and navigation leaves it alone. No `Loading` around it: it does +// no I/O, so the shell waits on it at no cost. async function getNav() { "use server"; return () => ( @@ -48,20 +49,21 @@ async function getNav() { ); } -// `defineRoute` types each route's component and preload from its own `path`, -// so the `:id` routes read `params.id` as `string` rather than -// `string | undefined`. The preloads make link hover/focus fetch the route's -// server component ahead of the click — same wiring as the SPA twin, and the -// preloaded boundary stays isolated until navigation actually reads it. +// The same route table as the SPA twin. The feeds and the user page are +// server routes: the router makes their calls from the match — params, and +// the feeds' `page` through `searchSchema` — on navigation and on link +// hover alike. The story route has a client half (the collapse fill), so it +// is an ordinary route component with its own `preload`. const Router = createRouter({ routes: [ defineRoute({ - path: ["/", "/top", "/new", "/show", "/ask", "/job"], - component: Stories, - preload: preloadStories + path: "/:type?", + matchFilters: { type: ["top", "new", "show", "ask", "job"] }, + search: searchSchema, + component: Stories }), defineRoute({ path: "/stories/:id", component: Story, preload: preloadStory }), - defineRoute({ path: "/users/:id", component: User, preload: preloadUser }) + defineRoute({ path: "/users/:id", component: User }) ] }); @@ -71,9 +73,7 @@ export default function App() { {props => ( <> - Loading...}> -