@weftui/core 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +8 -8
  2. package/dist/{index-Bsh2WLtx.d.ts → index-CX9uEejU.d.ts} +70 -51
  3. package/dist/index.d.ts +68 -119
  4. package/dist/index.js +1 -1
  5. package/dist/types/index.d.ts +1 -1
  6. package/docs/explanation/boundaries-and-suspense.md +39 -20
  7. package/docs/explanation/combinator-api.md +14 -12
  8. package/docs/explanation/reactive-primitives.md +14 -14
  9. package/docs/explanation/rendering-model.md +20 -18
  10. package/docs/explanation/services-and-context.md +35 -21
  11. package/docs/how-to/add-routing.md +361 -60
  12. package/docs/how-to/author-components.md +42 -30
  13. package/docs/how-to/compose-behavior-and-markup.md +144 -0
  14. package/docs/how-to/handle-forms.md +6 -6
  15. package/docs/how-to/load-async-data.md +15 -13
  16. package/docs/how-to/load-data-with-rpc.md +34 -32
  17. package/docs/how-to/provide-services.md +20 -18
  18. package/docs/how-to/render-keyed-lists.md +14 -12
  19. package/docs/how-to/render-on-the-server.md +18 -14
  20. package/docs/how-to/show-navigation-progress.md +12 -10
  21. package/docs/how-to/split-routes-lazily.md +16 -14
  22. package/docs/how-to/style-reactively.md +13 -13
  23. package/docs/how-to/use-element-refs.md +10 -8
  24. package/docs/index.md +20 -18
  25. package/docs/reference/core.md +46 -42
  26. package/docs/reference/dom.md +274 -58
  27. package/docs/reference/router.md +69 -47
  28. package/docs/tutorial/01-your-first-app.md +7 -9
  29. package/docs/tutorial/02-reactivity.md +8 -6
  30. package/docs/tutorial/03-services-and-async.md +10 -6
  31. package/docs/tutorial/04-errors-and-server.md +14 -5
  32. package/package.json +2 -2
@@ -2,17 +2,17 @@
2
2
  title: Routing
3
3
  order: 4
4
4
  section: how-to
5
- description: "@weftui/router universal nested routing, Router.route / Router.layout / Router.router, type-safe href, layouts, and programmatic navigation."
5
+ description: "@weftui/router: universal nested routing, Router.route / Router.layout / Router.router, type-safe href, layouts, and programmatic navigation."
6
6
  ---
7
7
 
8
8
  # Routing
9
9
 
10
10
  `@weftui/router` is a universal (server + client) nested router for Weft. It maps a URL to a rendered `Node` tree on both sides:
11
11
 
12
- - **Server** matches an incoming request path, renders the matched nested page to hydratable HTML, and responds with `text/html` (HTTP 404 for not-found).
13
- - **Client** matches `window.location`, swaps pages reactively via the History API, and keeps unchanged ancestor layouts mounted across navigations.
12
+ - **Server**: matches an incoming request path, renders to hydratable HTML.
13
+ - **Client**: matches reactively via the History API.
14
14
 
15
- The package mirrors `@weftui/dom`: a shared (universal) root, a `./client` entry, and a `./server` entry.
15
+ The package exports a shared (universal) root, a `./client` entry, and a `./server` entry.
16
16
 
17
17
  ```bash
18
18
  npm install @weftui/router
@@ -20,21 +20,34 @@ npm install @weftui/router
20
20
 
21
21
  ## The mental model
22
22
 
23
- A route's **component is its handler** a page is a component that renders, and its `component` slot is invoked at render time on whichever side the request arrives. Server-resolved data stays with [`Boundary.rpc`](https://weftui.dev/docs/how-to/load-data-with-rpc); client-side async stays with `Boundary.suspend`.
23
+ A route's **component is its handler**. A page is a component that renders, and its `component` slot is invoked at render time.
24
24
 
25
- You author an **explicit nested route tree** with three namespaced combinators — mirroring the `h.div` / `Component.gen` / `Boundary.catchTag` surface — and seal it once:
25
+ ```typescript
26
+ const homeRoute = Router.route("", { component: Home });
27
+ const aboutRoute = Router.route("about", { component: About });
28
+ const userRoute = Router.route("users/:id", {
29
+ path: { id: Schema.NumberFromString },
30
+ component: ({ path }) => h.h1(`User ${path.id}`),
31
+ });
26
32
 
27
- | Combinator | Builds |
28
- | ----------------------------------------------------- | ----------------------------------------------------------------- |
29
- | `Router.route(segment, { path?, query?, component })` | A leaf page. |
30
- | `Router.layout({ component }, children)` | A layout that wraps an outlet (purely UI nesting — owns no path). |
31
- | `Router.router(root, { notFound })` | Seals the tree into a `RouterDef`. |
33
+ const App = Router.router(Router.layout({ component: Shell }, [homeRoute, aboutRoute, userRoute]), {
34
+ notFound: () => h.h1("404"),
35
+ });
36
+ ```
37
+
38
+ You author a **nested route tree** with namespaced combinators and seal it once:
39
+
40
+ | Combinator | Builds |
41
+ | ----------------------------------------------------- | ---------------------------------------------------------------- |
42
+ | `Router.route(segment, { path?, query?, component })` | A leaf page. |
43
+ | `Router.layout({ component }, children)` | A layout that wraps an outlet (purely UI nesting; owns no path). |
44
+ | `Router.router(root, { notFound })` | Seals the tree into a `RouterDef`. |
32
45
 
33
46
  The tree is the source of truth. The same sealed `RouterDef` drives both server and client.
34
47
 
35
48
  ## Authoring routes
36
49
 
37
- Every `component` slot is a **`ComponentSlot`** a callable producing a `Node`, passed **uncalled**. Use [`Component.make` / `Component.gen`](https://weftui.dev/docs/how-to/author-components) (or a plain `() => Node` thunk). The router invokes it at render time, which is what lets `href(…)` resolve after the tree is compiled.
50
+ Every **`ComponentSlot`** produces a `Node` when called. Use [`Component.make` / `Component.gen`](https://weftui.dev/docs/how-to/author-components) (or a plain `() => Node` thunk). The router invokes it at render time, which lets `href(…)` resolve after the tree is compiled.
38
51
 
39
52
  ```typescript
40
53
  import { Component, h } from "@weftui/core";
@@ -54,18 +67,21 @@ const User = Router.route("users/:id", {
54
67
  });
55
68
  ```
56
69
 
57
- - **`segment`** is relative to the parent and may contain `:name` path-param placeholders (e.g. `"users/:id"`). A leading/trailing `/` is tolerated. Each leaf carries its full relative path (e.g. `"users/:id/settings"`).
58
- - **`path` / `query`** are `Schema.Struct.Fields` (a record of `name → Schema`), declared **only on routes**. The compiler covers every `:name` placeholder in `pathSchema`, defaulting to `Schema.String` when a placeholder has no declared field. Query fields are optional by default.
70
+ - **`segment`** is relative to the parent and may contain `:name` path-param placeholders (e.g. `"users/:id"`). A leading/trailing `/` is tolerated.
71
+
72
+ Each leaf carries its full relative path (e.g. `"users/:id/settings"`).
73
+
74
+ **`path` / `query`** are `Schema.Struct.Fields` (a record of `name → Schema`), declared **only on routes**. The compiler covers every `:name` placeholder in `pathSchema`, defaulting to `Schema.String` when a placeholder has no declared field. Query fields are optional by default.
59
75
 
60
- > Authoring components with `Component.make` / `Component.gen` keeps every slot fully typed: the router never sees a `Node<any, any>`, and each component's `E`/`R` channels aggregate up through `Router.layout` / `Router.router` into the sealed `RouterDef`.
76
+ > Authoring components with `Component.make` / `Component.gen` keeps every slot fully typed: Each component's `E`/`R` channels aggregate up through `Router.layout` / `Router.router` into the sealed `RouterDef`.
61
77
 
62
78
  ## Reading the match: handler-arg props vs. injection
63
79
 
64
- A leaf page reads the current match's decoded `path` / `query` in **either** of two forms.
80
+ A leaf page reads the current match's decoded `path` / `query` in either of two forms.
65
81
 
66
82
  ### Handler-arg props (leaf pages)
67
83
 
68
- The router passes the decoded `{ path, query }` straight into a leaf `component` as props (typed `RouteHandlerProps<Path, Query>`, inferred from the route's `path` / `query` fields). No `Router` access, no validation step just read the props:
84
+ The router passes the decoded `{ path, query }` straight into a leaf `component` as props. The props are typed `RouteHandlerProps<Path, Query>`, inferred from the route's `path` / `query` fields. No `Router` access, no validation step. Just read the props:
69
85
 
70
86
  ```typescript
71
87
  const idParam = { id: Schema.NumberFromString };
@@ -80,7 +96,7 @@ Router.route("users/:id/posts", {
80
96
  });
81
97
  ```
82
98
 
83
- This is the most direct form for a leaf. A plain zero-arg thunk works too it just ignores the props.
99
+ This is the most direct form for a leaf. A plain zero-arg thunk works too; it just ignores the props.
84
100
 
85
101
  ### Dependency injection (layouts and deep nodes)
86
102
 
@@ -95,13 +111,35 @@ const UserShell = Component.gen(function* () {
95
111
  });
96
112
  ```
97
113
 
98
- `Router.params(fields)` / `Router.query(fields)` read the live match and pick the requested `fields` keys (already decoded by the matcher, so no re-validation). They return the typed values, or fail with a tagged [`RouterParamsError`](#errors) (carrying `source: "path" | "query"` and the requested `keys`) when no route matches. That error bubbles into the app node's aggregate `E`, so a user may recover it with `Boundary.catchTag("RouterParamsError", …)`.
114
+ `Router.params(fields)` / `Router.query(fields)` read the live match and pick the requested `fields` keys (already decoded by the matcher, so no re-validation). They return the typed values. When no route matches, they fail with a tagged [`RouterParamsError`](#errors) carrying `source: "path" | "query"` and the requested `keys`.
115
+
116
+ That error bubbles into the app node's aggregate `E`, so a user may recover it with `Boundary.catchTag(…)`.
117
+
118
+ ### Reactive accessors: `paramsStream` / `queryStream`
119
+
120
+ `Router.paramsStream(fields)` / `Router.queryStream(fields)` are the reactive counterparts of `params` / `query`. Each resolves a `Subscribable` derived from `Subscribable.changes(currentMatch)`, so a component can update **in place** even when the same leaf stays mounted, the case a query-only navigation (`setQuery` / `patchQuery`, see [Programmatic navigation](#programmatic-navigation)) produces and a snapshot `Router.query` would miss:
121
+
122
+ ```typescript
123
+ import { Component, h, Subscribable } from "@weftui/core";
124
+ import { Router } from "@weftui/router";
125
+ import { Schema, Stream } from "effect";
126
+
127
+ const sortQuery = { sort: Schema.optional(Schema.String) };
128
+
129
+ const ProductsPage = Component.gen(function* () {
130
+ const query = yield* Router.queryStream(sortQuery);
131
+ return yield* h.section([
132
+ h.h2("Products"),
133
+ h.p(["sort: ", Stream.map(Subscribable.changes(query), (q) => q.sort ?? "none")]),
134
+ ]);
135
+ });
136
+ ```
99
137
 
100
- > **Reactive accessors.** `Router.paramsStream(fields)` / `Router.queryStream(fields)` are the reactive counterparts each resolves a `Subscribable` derived from `currentMatch.changes`, so a component can render `[(yield* Router.queryStream(sortQuery)).changes]` and update **in place** even when the same leaf stays mounted (the query-only case `Router.query` would miss). See [Programmatic navigation](#programmatic-navigation).
138
+ A `NotFound` match yields the empty subset rather than failing, so the stream stays live across navigations.
101
139
 
102
140
  ## Layouts and the outlet
103
141
 
104
- A **layout** wraps the next level down the **outlet** which is also delivered by injection. A layout reads it with `yield* Router.Outlet` and places it like any `h`-style child:
142
+ A **layout** wraps the next level down: the **outlet**, which is also delivered by injection. A layout reads it with `yield* Router.Outlet` and places it like any `h`-style child:
105
143
 
106
144
  ```typescript
107
145
  const UserShell = Component.gen(function* () {
@@ -113,14 +151,37 @@ const UserShell = Component.gen(function* () {
113
151
  Router.layout({ component: UserShell }, [settingsRoute, postsRoute]);
114
152
  ```
115
153
 
116
- `Router.Outlet` is typed **opaque** (`Node<never, never>`), so splicing it adds nothing to the layout's own channels the subtree's real `E`/`R` are aggregated structurally by `Router.layout`. The router discharges the `Outlet` requirement at render time, so it never appears in a layout's (or the sealed app's) aggregate requirement channel.
154
+ `Router.Outlet` is typed **opaque** (`Node<never, never>`), so splicing it adds nothing to the layout's own channels. The subtree's real `E`/`R` are aggregated structurally by `Router.layout`. The router discharges the `Outlet` requirement at render time, so it never appears in a layout's (or the sealed app's) aggregate requirement channel.
117
155
 
118
- A layout owns **no `segment` or `path`** all path structure lives on routes. A layout that needs a param simply reads it via `Router.params`.
156
+ A layout owns **no `segment` or `path`**; all path structure lives on routes. A layout that needs a param reads it via `Router.params`.
119
157
 
120
158
  ### Layout persistence
121
159
 
122
160
  Each nesting level renders as a reactive stream child keyed by `(pattern + the param values that level depends on)` and `dedupe`d. An unchanged ancestor layout therefore **stays mounted** across a navigation that only changes a deeper level: its DOM identity and any local state (a `SubscriptionRef`, a scroll position) survive while only the inner outlet swaps.
123
161
 
162
+ ```typescript
163
+ import { Component, h } from "@weftui/core";
164
+ import { Router } from "@weftui/router";
165
+ import { Clock } from "effect";
166
+
167
+ // UserShell's body runs once per distinct `:id`. Navigating between
168
+ // /users/1/settings and /users/1/posts doesn't change `:id`, so this
169
+ // instance (and `sessionStart`) is never recreated: only `outlet` swaps.
170
+ const UserShell = Component.gen(function* () {
171
+ const { id } = yield* Router.params(idParam);
172
+ const outlet = yield* Router.Outlet;
173
+ const sessionStart = yield* Clock.currentTimeMillis;
174
+
175
+ return yield* h.div({ class: "user" }, [
176
+ h.p(`shell mounted at ${sessionStart}`),
177
+ h.h1(`User ${id}`),
178
+ outlet,
179
+ ]);
180
+ });
181
+ ```
182
+
183
+ Navigate from `/users/1/settings` to `/users/1/posts` and the mounted timestamp stays the same; navigate to `/users/2/settings` and it re-renders, since `:id` changed.
184
+
124
185
  ## Sealing the tree
125
186
 
126
187
  `Router.router(root, { notFound })` compiles the tree eagerly (stamping leaf references so `href` works) and captures the app-level not-found page:
@@ -131,11 +192,11 @@ export const App = Router.router(
131
192
  homeRoute,
132
193
  Router.layout({ component: UserShell }, [settingsRoute, postsRoute]),
133
194
  ]),
134
- { notFound: () => h.section({ id: "page" }, [h.h2("404 page not found")]) },
195
+ { notFound: () => h.section({ id: "page" }, [h.h2("404: page not found")]) },
135
196
  );
136
197
  ```
137
198
 
138
- `App` is a `RouterDef` whose phantom `E`/`R` carry the aggregate channels of the whole tree (plus the not-found page) keep `app.ts` side-effect-free (no `mount`/`hydrate`) so both entries can import it.
199
+ `App` is a `RouterDef` whose phantom `E`/`R` carry the aggregate channels of the whole tree (plus the not-found page). Keep `app.ts` side-effect-free (no `mount`/`hydrate`) so both entries can import it.
139
200
 
140
201
  ## Type-safe links with `href`
141
202
 
@@ -152,11 +213,13 @@ const Home = Component.make(() =>
152
213
  );
153
214
  ```
154
215
 
155
- Path params encode into the pattern (`/users/:id` + `{ id: 42 }` ⇒ `/users/42`); query values encode through the query schema into a key-sorted search string. `href` round-trips with the matcher. The leaf must belong to a tree sealed with `Router.router()` (which is why deferring the `component` body via `Component.make` matters — `href` runs at render time, after compile).
216
+ Path params encode into the pattern (`/users/:id` + `{ id: 42 }` ⇒ `/users/42`). Query values encode through the query schema into a key-sorted search string. `href` round-trips with the matcher.
217
+
218
+ The leaf must belong to a tree sealed with `Router.router()`. This is why deferring the `component` body via `Component.make` matters: `href` runs at render time, after compile.
156
219
 
157
220
  ## Not-found
158
221
 
159
- `notFound(path?)` short-circuits the current render with a `RouterNotFound` failure. Callable from any page or layout; the nearest enclosing not-found boundary renders the configured `notFound` page in its place, and the server responds with HTTP 404:
222
+ `notFound(path?)` short-circuits the current render with a `RouterNotFound` failure. Callable from any page or layout. The nearest enclosing not-found boundary renders the configured `notFound` page in its place, and the server responds with HTTP 404:
160
223
 
161
224
  ```typescript
162
225
  import { notFound, Router } from "@weftui/router";
@@ -171,31 +234,128 @@ Router.route("users/:id", {
171
234
  });
172
235
  ```
173
236
 
174
- `RouterNotFound` is exported, so a `Boundary.catchTag("RouterNotFound", …)` placed inside a subtree overrides the app-level fallback for that subtree (the router's internal boundary is outermost, so a nearer user boundary wins).
237
+ `RouterNotFound` is exported, so a `Boundary.catchTag(…)` placed inside a subtree overrides the app-level fallback for that subtree. The router's internal boundary is outermost, so a nearer user boundary wins.
175
238
 
176
- > **`Schema.NumberFromString` gotcha.** Decoding no longer fails on a non-numeric segment `/users/abc` decodes `id` to `NaN` instead of missing the route. A leaf that guards a numeric param must check `Number.isFinite(id)` itself (as above); relying on the schema alone to 404 non-numeric input no longer works.
239
+ > **`Schema.NumberFromString` gotcha.** Decoding no longer fails on a non-numeric segment: `/users/abc` decodes `id` to `NaN` instead of missing the route. A leaf that guards a numeric param must check `Number.isFinite(id)` itself (as above). Relying on the schema alone to 404 non-numeric input no longer works.
177
240
 
178
241
  ## Client setup
179
242
 
180
- On the client, provide the `Router` via `RouterLive(def)` and render `RouterApp(def)`. `RouterLive` is a **scoped layer** it owns the `popstate` listener and the same-origin link-click interceptor so it must outlive the mount. Give it to `WeftApp.make`: the app runtime owns it for the app's lifetime, built lazily on first hydrate and released only at `WeftApp.dispose`. Do not wrap `Effect.provide` around the mount/hydrate call — services come exclusively from the app layer.
243
+ On the client, provide the `Router` via `RouterLive(def)` and render `RouterApp(def)`. `RouterLive` is a **scoped layer**: it owns the `popstate` listener and the same-origin link-click interceptor, so it must outlive the mount.
244
+
245
+ Give it to `WeftApp.make`. The app runtime owns it for the app's lifetime, built lazily on first mount/hydrate and released only at `WeftApp.dispose`. Do not wrap `Effect.provide` around the mount/hydrate call; services come exclusively from the app layer. `RouterLive`'s only required argument is the sealed `App`; a second `options` argument adds an rpc group or a custom `baseUrl` when needed (see [`Boundary.rpc` interplay](#boundaryrpc-interplay)).
246
+
247
+ ```typescript
248
+ const app = WeftApp.make(RouterLive(App));
249
+ void Effect.runPromise(WeftApp.mount(app, RouterApp(App), root));
250
+ ```
251
+
252
+ ### Client-only app
253
+
254
+ A complete, no-SSR app: three routes under one `Shell` layout, mounted directly into an empty `#root`. This is the whole file set, copy/paste runnable in a `vite` + `@weftui/router` project.
255
+
256
+ ```html
257
+ <!-- index.html -->
258
+ <!doctype html>
259
+ <html lang="en">
260
+ <head>
261
+ <meta charset="UTF-8" />
262
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
263
+ <title>Weft routing demo</title>
264
+ </head>
265
+ <body>
266
+ <div id="root"></div>
267
+ <script type="module" src="/src/main.ts"></script>
268
+ </body>
269
+ </html>
270
+ ```
271
+
272
+ ```typescript
273
+ // src/app.ts
274
+ /**
275
+ * Client-only routing demo: a Shell layout with Home, About, and a dynamic
276
+ * User page, sealed into a single RouterDef. Side-effect-free (no mount call),
277
+ * so `main.ts` and any test can import `App` directly.
278
+ */
279
+ import { Component, h } from "@weftui/core";
280
+ import { href, notFound, Router } from "@weftui/router";
281
+ import { Schema } from "effect";
282
+
283
+ const idParam = { id: Schema.NumberFromString };
284
+
285
+ const homeRoute = Router.route("", {
286
+ component: Component.make(() => h.section({ id: "page" }, [h.h2("Home")])),
287
+ });
288
+
289
+ const aboutRoute = Router.route("about", {
290
+ component: Component.make(() => h.section({ id: "page" }, [h.h2("About")])),
291
+ });
292
+
293
+ const userRoute = Router.route("users/:id", {
294
+ path: idParam,
295
+ component: ({ path }) => {
296
+ if (!Number.isFinite(path.id) || path.id < 0) return notFound();
297
+ return h.section({ id: "page" }, [h.h2(`User ${path.id}`)]);
298
+ },
299
+ });
300
+
301
+ const Shell = Component.gen(function* () {
302
+ const outlet = yield* Router.Outlet;
303
+ return yield* h.div({ id: "app" }, [
304
+ h.nav([
305
+ h.a({ href: href(homeRoute) }, "Home"),
306
+ " · ",
307
+ h.a({ href: href(aboutRoute) }, "About"),
308
+ " · ",
309
+ h.a({ href: href(userRoute, { path: { id: 1 } }) }, "User 1"),
310
+ ]),
311
+ h.main([outlet]),
312
+ ]);
313
+ });
314
+
315
+ export const App = Router.router(
316
+ Router.layout({ component: Shell }, [homeRoute, aboutRoute, userRoute]),
317
+ { notFound: () => h.section({ id: "page" }, [h.h2("404: page not found")]) },
318
+ );
319
+ ```
181
320
 
182
321
  ```typescript
183
- // entry-client.ts
322
+ // src/main.ts
323
+ /**
324
+ * Browser entry: mounts the routing demo into `#root`. No server render to
325
+ * hydrate, so this uses `WeftApp.mount`, not `hydrate`.
326
+ */
184
327
  import { WeftApp } from "@weftui/dom/client";
185
328
  import { RouterApp, RouterLive } from "@weftui/router/client";
186
329
  import { Effect } from "effect";
187
330
  import { App } from "./app";
188
331
 
189
- const root = document.getElementById("root")!;
332
+ const root = document.getElementById("root");
333
+ if (root === null) {
334
+ throw new Error("#root not found");
335
+ }
336
+
190
337
  const app = WeftApp.make(RouterLive(App));
191
- void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));
338
+ void Effect.runPromise(WeftApp.mount(app, RouterApp(App), root));
192
339
  ```
193
340
 
194
- For a client-only app (no SSR), swap `WeftApp.hydrate` for `WeftApp.mount` everything else is identical.
341
+ `WeftApp.mount(app, node, root)` clears `root` and renders `node` fresh, in contrast to `hydrate`, which adopts existing server-rendered DOM (see [Full SSR example](#full-ssr-example) below). Everything else, the layout, `href`, params, navigation, is identical between the two setups.
195
342
 
196
343
  ### Link interception
197
344
 
198
- A plain `h.a({ href })` to a same-origin, route-matching URL performs SPA navigation when clicked no full page load. The interceptor leaves the browser's native behaviour untouched for modified clicks (ctrl/meta/shift/alt or non-left button), `target=_blank`, `download`, external origins, same-document (hash-only) navigations, and hrefs that don't resolve to a route. You don't wire anything up: `RouterLive` installs the delegated listener for the layer's lifetime and removes it on teardown.
345
+ A plain `h.a({ href })` to a same-origin, route-matching URL performs SPA navigation when clicked: no full page load. The interceptor leaves the browser's native behaviour untouched for:
346
+
347
+ - modified clicks (ctrl/meta/shift/alt or non-left button)
348
+ - `target=_blank` and `download`
349
+ - external origins
350
+ - same-document (hash-only) navigations
351
+ - hrefs that don't resolve to a route
352
+
353
+ ```typescript
354
+ h.a({ href: "/about" }, "About"); // intercepted: SPA navigation, no reload
355
+ h.a({ href: "/about", target: "_blank" }, "About"); // native: falls through
356
+ ```
357
+
358
+ You don't wire anything up. `RouterLive` installs the delegated listener for the layer's lifetime and removes it on teardown.
199
359
 
200
360
  ## Programmatic navigation
201
361
 
@@ -230,27 +390,119 @@ yield * setQuery({ sort: "old" }); // replaces the query
230
390
  yield * patchQuery({ sort: "old" }); // merges into the current query
231
391
  ```
232
392
 
233
- - **`navigate(ref, args)`** builds the URL via [`href`](#type-safe-links-with-href) (so it round-trips with the matcher) and pushes or, with `{ replace: true }`, replaces — the History entry. `args` follows the same requiredness rules as `href`.
234
- - **`setQuery` / `patchQuery`** keep the path, so the active leaf is never remounted pair them with `Router.queryStream` for in-place reactive updates. They are a no-op when no route is matched.
393
+ - **`navigate(ref, args)`** builds the URL via [`href`](#type-safe-links-with-href), so it round-trips with the matcher. It pushes the History entry, or replaces it with `{ replace: true }`. `args` follows the same requiredness rules as `href`.
394
+ - **`setQuery` / `patchQuery`** keep the path, so the active leaf is never remounted. Pair them with `Router.queryStream` for in-place reactive updates. They are a no-op when no route is matched.
235
395
 
236
396
  ### Scroll position on navigation
237
397
 
238
- A client navigation whose **path** changes resets the window scroll to the top at commit matching what a full page load would do, which a raw History `pushState`/`replaceState` otherwise doesn't. This applies uniformly to `Router.navigate`, clicking a link the [interceptor](#link-interception) handles, and the `push` / `replace` helpers.
398
+ A client navigation whose **path** changes resets the window scroll to the top at commit. This matches a full page load, which a raw History `pushState`/`replaceState` otherwise doesn't. It applies uniformly to `Router.navigate`, clicking a link the [interceptor](#link-interception) handles, and the `push` / `replace` helpers.
239
399
 
240
- - **Query-only navigations preserve scroll.** `setQuery` / `patchQuery` (and any navigation that keeps the same path) don't reset the leaf stays mounted, so there's nothing to scroll away from.
400
+ - **Query-only navigations preserve scroll.** `setQuery` / `patchQuery` (and any navigation that keeps the same path) don't reset; the leaf stays mounted, so there's nothing to scroll away from.
241
401
  - **Back/forward is untouched.** The router never resets scroll on `popstate`; the browser's native `history.scrollRestoration: "auto"` restores the offset the entry had when the user left it.
242
- - **Hash navigation (`#section`) is unaffected** — it's browser-native, and the link interceptor already lets same-document/hash-only clicks fall through.
402
+ - **Hash navigation (`#section`) is unaffected.** It's browser-native, and the link interceptor already lets same-document/hash-only clicks fall through.
243
403
 
244
404
  There's no opt-out; the behavior is hardwired.
245
405
 
246
406
  ## Server setup
247
407
 
248
- On the server, `RouterServer` matches a request URL, builds a fixed-match `Router`, renders `RouterApp` to hydratable HTML inside a **document shell**, and reports a status (404 when no route matches or a page raises `RouterNotFound`).
408
+ On the server, `RouterServer`:
249
409
 
250
- The document shell is itself a `ComponentSlot` that splices the app via `yield* Router.Outlet` — exactly like a layout receives its outlet:
410
+ - matches a request URL and builds a fixed-match `Router`
411
+ - renders `RouterApp` to hydratable HTML inside a **document shell**
412
+ - reports a status (404 when no route matches or a page raises `RouterNotFound`)
413
+
414
+ The document shell is itself a `ComponentSlot` that splices the app via `yield* Router.Outlet`, exactly like a layout receives its outlet.
251
415
 
252
416
  ```typescript
253
- // entry-server.ts
417
+ const { html, status } = await Effect.runPromise(
418
+ RouterServer.render(App, { document: documentShell, url }),
419
+ );
420
+ ```
421
+
422
+ ### Full SSR example
423
+
424
+ The same three routes as the [client-only app](#client-only-app), rendered on the server as hydratable HTML and hydrated in the browser. This is the whole file set (drop it alongside a dev server that bridges `entry-server.ts`'s `handler` into Vite or any Web-platform server; see [`examples/router-ssr/server.ts`](https://github.com/stefvw93/weft/blob/main/examples/router-ssr/server.ts) for a working one).
425
+
426
+ ```typescript
427
+ // src/app.ts
428
+ /**
429
+ * Shared, isomorphic router app: three pages under one persistent Shell
430
+ * layout. Side-effect-free: it never mounts or serves. `entry-server.ts`
431
+ * renders the matched route on the server; `entry-client.ts` hydrates over it.
432
+ */
433
+ import { Component, h } from "@weftui/core";
434
+ import { href, notFound, Router } from "@weftui/router";
435
+ import { Schema } from "effect";
436
+
437
+ const idParam = { id: Schema.NumberFromString };
438
+
439
+ export const homeRoute = Router.route("", {
440
+ component: Component.make(() => h.section({ id: "page" }, [h.h2("Home")])),
441
+ });
442
+
443
+ export const aboutRoute = Router.route("about", {
444
+ component: Component.make(() => h.section({ id: "page" }, [h.h2("About")])),
445
+ });
446
+
447
+ export const userRoute = Router.route("users/:id", {
448
+ path: idParam,
449
+ component: ({ path }) => {
450
+ if (!Number.isFinite(path.id) || path.id < 0) return notFound();
451
+ return h.section({ id: "page" }, [h.h2(`User ${path.id}`)]);
452
+ },
453
+ });
454
+
455
+ const Shell = Component.gen(function* () {
456
+ const outlet = yield* Router.Outlet;
457
+ return yield* h.div({ id: "app" }, [
458
+ h.nav([
459
+ h.a({ href: href(homeRoute) }, "Home"),
460
+ " · ",
461
+ h.a({ href: href(aboutRoute) }, "About"),
462
+ ]),
463
+ h.main([outlet]),
464
+ ]);
465
+ });
466
+
467
+ export const App = Router.router(
468
+ Router.layout({ component: Shell }, [homeRoute, aboutRoute, userRoute]),
469
+ { notFound: () => h.section({ id: "page" }, [h.h2("404: page not found")]) },
470
+ );
471
+ ```
472
+
473
+ ```typescript
474
+ // src/entry-client.ts
475
+ /**
476
+ * Client entry: hydrates the server-rendered markup in `#root`.
477
+ *
478
+ * `RouterApp(App)` is the universal router root; `RouterLive(App)` provides
479
+ * the History-API-backed `Router` (seeded from `window.location`, with the
480
+ * same-origin link click interceptor installed). `hydrate` adopts the server
481
+ * DOM in place and resumes the reactive outlet.
482
+ */
483
+ import { WeftApp } from "@weftui/dom/client";
484
+ import { RouterApp, RouterLive } from "@weftui/router/client";
485
+ import { Effect } from "effect";
486
+ import { App } from "./app";
487
+
488
+ const root = document.getElementById("root");
489
+ if (root === null) {
490
+ throw new Error("#root not found");
491
+ }
492
+
493
+ const app = WeftApp.make(RouterLive(App));
494
+ void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));
495
+ ```
496
+
497
+ ```typescript
498
+ // src/entry-server.ts
499
+ /**
500
+ * Server entry: renders the matched route to a hydratable HTML document.
501
+ *
502
+ * `documentShell` splices the app via `yield* Router.Outlet` (injected per
503
+ * request by `RouterServer`). `render` drives it for a single `url`; `handler`
504
+ * is a Web `fetch`-style handler ready to bridge into Vite or any Web server.
505
+ */
254
506
  import { Component, h } from "@weftui/core";
255
507
  import { Router } from "@weftui/router";
256
508
  import { RouterServer } from "@weftui/router/server";
@@ -268,41 +520,90 @@ const documentShell = Component.gen(function* () {
268
520
  ]);
269
521
  });
270
522
 
271
- // { html, status } `<!DOCTYPE html>` is prepended for you.
272
- export const render = (url: string) =>
523
+ // { html, status }: `<!DOCTYPE html>` is prepended for you.
524
+ export const render = (url: string): Promise<{ html: string; status: number }> =>
273
525
  Effect.runPromise(RouterServer.render(App, { document: documentShell, url }));
274
526
 
275
- // Or a Web fetch-style handler, ready to bridge into Vite or any Web server.
527
+ // A Web fetch-style handler, ready to bridge into Vite or any Web server.
276
528
  export const handler = RouterServer.toWebHandler(App, { document: documentShell });
277
529
  ```
278
530
 
279
- `render` provides both `Router.Outlet` (the app, per request) and `Router` (so the shell may read params), and renders through `renderToStringHydratable` so the client can `hydrate` in place.
531
+ `render` provides both `Router.Outlet` (the app, per request) and `Router` (so the shell may read params). It renders through `renderToStringHydratable` so the client can `hydrate` in place. Neither `RouterLive` nor `RouterServer` needs an `rpc` option here: it's optional and only required once a page uses [`Boundary.rpc`](#boundaryrpc-interplay).
532
+
533
+ `handler` still needs a server to call it. [`examples/router-ssr/server.ts`](https://github.com/stefvw93/weft/blob/main/examples/router-ssr/server.ts) shows the shape: a Node HTTP server that runs Vite in middleware mode, converts each request to a Web `Request`, calls `handler`, and runs HTML responses through `vite.transformIndexHtml` for HMR (non-HTML responses, like a `Boundary.rpc` refetch, are forwarded untouched). See that file and its co-located [`vite.config.ts`](https://github.com/stefvw93/weft/blob/main/examples/router-ssr/vite.config.ts) for the full dev-server wiring; it's the same shape in production behind any Web-platform host.
280
534
 
281
535
  ### `effect/unstable/httpapi` is the spine
282
536
 
283
- The tree is the authoring surface, but `effect/unstable/httpapi`'s `HttpApi` is the **single source of truth** for paths and schemas. Sealing the tree with `Router.router(...)` builds it once (`buildHttpApi`) and stamps it onto `def.httpApi`: a single `"pages"` group with one GET endpoint per leaf at its full path pattern, carrying `params: pathSchema`, `query: querySchema`, and a `RouterNotFound → 404` error. Both sides read that one definition, so they always agree:
537
+ The tree is the authoring surface, but `effect/unstable/httpapi`'s `HttpApi` is the **single source of truth** for paths and schemas. Sealing the tree with `Router.router(...)` builds it once (`buildHttpApi`) and stamps it onto `def.httpApi`.
538
+
539
+ The result is a single `"pages"` group with one GET endpoint per leaf at its full path pattern, carrying `params: pathSchema`, `query: querySchema`, and a `RouterNotFound → 404` error. Both sides read that one definition, so they always agree:
540
+
541
+ - **Server**: `RouterServer` dispatches through `HttpApiBuilder` (platform owns request→leaf matching, path/query decode, and the 404 status).
542
+ - **Client**: `RouterLive` derives a real `HttpApiClient` from the same `def.httpApi` (exposed as `Router.httpApiClient`) for network work. SPA URL→leaf resolution stays **local**; there is no public client-side "match this URL against my `HttpApi`" utility in platform. It is fed from the same endpoint definitions, so it never drifts from the server.
543
+
544
+ ```typescript
545
+ import { Option } from "effect";
546
+
547
+ App.httpApi; // HttpApi.Top: one "pages" group, a GET endpoint per leaf
284
548
 
285
- - **Server** `RouterServer` dispatches through `HttpApiBuilder` (platform owns request→leaf matching, path/query decode, and the 404 status).
286
- - **Client** — `RouterLive` derives a real `HttpApiClient` from the same `def.httpApi` (exposed as `Router.httpApiClient`) for network work. SPA URL→leaf resolution stays **local** (there is no public client-side "match this URL against my `HttpApi`" utility in platform), fed from the same endpoint definitions so it never drifts from the server.
549
+ const { httpApiClient } = yield * Router;
550
+ Option.isSome(httpApiClient); // true under RouterLive, false under RouterServer
551
+ ```
287
552
 
288
553
  ## Errors
289
554
 
290
- | Error | Raised by | Recover with |
291
- | ------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------- |
292
- | `RouterNotFound` | `notFound()`, or no route matched | `Boundary.catchTag("RouterNotFound", …)` (or the app-level `notFound` page) |
293
- | `RouterParamsError` | `Router.params` / `Router.query` on a missing/invalid key or no match | `Boundary.catchTag("RouterParamsError", …)` |
555
+ | Error | Raised by | Recover with |
556
+ | ------------------- | --------------------------------------------------------------------- | --------------------------------------------------------- |
557
+ | `RouterNotFound` | `notFound()`, or no route matched | `Boundary.catchTag(…)` (or the app-level `notFound` page) |
558
+ | `RouterParamsError` | `Router.params` / `Router.query` on a missing/invalid key or no match | `Boundary.catchTag(…)` |
294
559
 
295
560
  Both are modeled as `Schema.TaggedErrorClass`, so they encode/decode across the wire the same way `Boundary.rpc` replays typed failures.
296
561
 
562
+ Recover locally by wrapping just the subtree that can fail, rather than relying on the app-level `notFound` page for everything:
563
+
564
+ ```typescript
565
+ import { Boundary, Component, h } from "@weftui/core";
566
+ import { Router } from "@weftui/router";
567
+
568
+ const UserShell = Component.gen(function* () {
569
+ const outlet = yield* Router.Outlet;
570
+ return yield* h.div({ class: "user" }, [
571
+ Boundary.catchTag(
572
+ {
573
+ tag: "RouterParamsError",
574
+ fallback: () => h.p({ class: "error" }, "Couldn't read this page's params."),
575
+ },
576
+ [outlet],
577
+ ),
578
+ ]);
579
+ });
580
+ ```
581
+
582
+ The matched tag is removed from the boundary's output `E`; an unmatched error (e.g. `RouterNotFound`) re-raises to the nearest parent boundary, which is the router's own not-found boundary if nothing closer catches it.
583
+
297
584
  ## `Boundary.rpc` interplay
298
585
 
299
- Initial SSR navigation works end to end: the server resolves the rpc and inlines its payload, and the client replays it during `hydrate`. **Client-side** navigation into a page containing a `Boundary.rpc` has no SSR payload, so the boundary performs a **client-first mount** — it renders the boundary's `fallback`, forks the rpc call over `POST /_eui/rpc`, and swaps in the result. `@weftui/router` provides the `AppRpcClientTag` seam on both sides (network client on the client, in-process on the server), so the same rpc backs SSR-replay, refetch, and client-first mount. See the [rpc data boundaries guide](https://weftui.dev/docs/how-to/load-data-with-rpc).
586
+ Initial SSR navigation works end to end: the server resolves the rpc and inlines its payload, and the client replays it during `hydrate`.
587
+
588
+ **Client-side** navigation into a page containing a `Boundary.rpc` has no SSR payload, so the boundary performs a **client-first mount**. It renders the boundary's `fallback`, forks the rpc call over `POST /_eui/rpc`, and swaps in the result.
589
+
590
+ `@weftui/router` provides the `AppRpcClientTag` seam on both sides (network client on the client, in-process on the server). The same rpc backs SSR-replay, refetch, and client-first mount. Both `RouterLive` and `RouterServer` take an optional `{ rpc: { group } }` (server also needs `handlers`) to wire it: see the [rpc data boundaries guide](https://weftui.dev/docs/how-to/load-data-with-rpc) and [`examples/router-ssr`](https://github.com/stefvw93/weft/tree/main/examples/router-ssr) for the full contract/handler split.
591
+
592
+ ```typescript
593
+ // client (entry-client.ts): network rpc client over the shared group
594
+ const app = WeftApp.make(RouterLive(App, { rpc: { group: StockRpcs } }));
595
+
596
+ // server (entry-server.ts): same group, plus its handler Layer
597
+ const rpc = { group: StockRpcs, handlers: StockLive };
598
+ export const handler = RouterServer.toWebHandler(App, { document: documentShell, rpc });
599
+ ```
300
600
 
301
601
  ## See also
302
602
 
303
603
  - [`@weftui/router` API reference](https://weftui.dev/docs/reference/router)
304
- - [examples/router-ssr](https://github.com/stefvw93/weft/tree/main/examples/router-ssr) a runnable SSR + hydration app with nested layouts, persistent layout state, type-safe `href`s, handler-arg props, and programmatic navigation over the `effect/unstable/httpapi` spine
305
- - [Component Authoring](https://weftui.dev/docs/how-to/author-components) `Component.make` / `Component.gen`, the idiomatic way to write route components
306
- - [Server-Side Rendering](https://weftui.dev/docs/how-to/render-on-the-server) `renderToStringHydratable`, `hydrate`, and `Boundary.rpc`
307
- - [RPC Data Boundaries](https://weftui.dev/docs/how-to/load-data-with-rpc) `Boundary.rpc`, the `Resource` handle, and the four lifecycles
308
- - [`packages/router/router.specs.md`](https://github.com/stefvw93/weft/blob/main/packages/router/router.specs.md) the full specification
604
+ - [examples/router-ssr](https://github.com/stefvw93/weft/tree/main/examples/router-ssr): a runnable SSR + hydration app with nested layouts, persistent layout state, type-safe `href`s, handler-arg props, `Boundary.rpc`, and programmatic navigation over the `effect/unstable/httpapi` spine
605
+ - [examples/router-client](https://github.com/stefvw93/weft/tree/main/examples/router-client): the client-only counterpart, no server, no SSR, no `Boundary.rpc`
606
+ - [Component Authoring](https://weftui.dev/docs/how-to/author-components): `Component.make` / `Component.gen`, the idiomatic way to write route components
607
+ - [Server-Side Rendering](https://weftui.dev/docs/how-to/render-on-the-server): `renderToStringHydratable`, `hydrate`, and `Boundary.rpc`
608
+ - [RPC Data Boundaries](https://weftui.dev/docs/how-to/load-data-with-rpc): `Boundary.rpc`, the `Resource` handle, and the four lifecycles
609
+ - [`packages/router/router.specs.md`](https://github.com/stefvw93/weft/blob/main/packages/router/router.specs.md): the full specification