@weftui/core 0.29.0 → 0.31.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.
@@ -9,10 +9,10 @@ description: "@weftui/router: universal nested routing, Router.route / Router.la
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,9 +20,22 @@ 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
+ });
32
+
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:
26
39
 
27
40
  | Combinator | Builds |
28
41
  | ----------------------------------------------------- | ---------------------------------------------------------------- |
@@ -34,7 +47,7 @@ The tree is the source of truth. The same sealed `RouterDef` drives both server
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 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,10 +67,13 @@ 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>`. 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
 
@@ -97,9 +113,29 @@ const UserShell = Component.gen(function* () {
97
113
 
98
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`.
99
115
 
100
- That error bubbles into the app node's aggregate `E`, so a user may recover it with `Boundary.catchTag("RouterParamsError", …)`.
116
+ That error bubbles into the app node's aggregate `E`, so a user may recover it with `Boundary.catchTag(…)`.
101
117
 
102
- > **Reactive accessors.** `Router.paramsStream(fields)` / `Router.queryStream(fields)` are the reactive counterparts. Each resolves a `Subscribable` derived from `currentMatch.changes`. 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).
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
+ ```
137
+
138
+ A `NotFound` match yields the empty subset rather than failing, so the stream stays live across navigations.
103
139
 
104
140
  ## Layouts and the outlet
105
141
 
@@ -121,7 +157,30 @@ A layout owns **no `segment` or `path`**; all path structure lives on routes. A
121
157
 
122
158
  ### Layout persistence
123
159
 
124
- 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.
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.
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.
125
184
 
126
185
  ## Sealing the tree
127
186
 
@@ -175,7 +234,7 @@ Router.route("users/:id", {
175
234
  });
176
235
  ```
177
236
 
178
- `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.
179
238
 
180
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.
181
240
 
@@ -183,21 +242,100 @@ Router.route("users/:id", {
183
242
 
184
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.
185
244
 
186
- 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.
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)).
187
246
 
188
247
  ```typescript
189
- // entry-client.ts
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
+ ```
320
+
321
+ ```typescript
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
+ */
190
327
  import { WeftApp } from "@weftui/dom/client";
191
328
  import { RouterApp, RouterLive } from "@weftui/router/client";
192
329
  import { Effect } from "effect";
193
330
  import { App } from "./app";
194
331
 
195
332
  const root = document.getElementById("root")!;
333
+
196
334
  const app = WeftApp.make(RouterLive(App));
197
- void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));
335
+ void Effect.runPromise(WeftApp.mount(app, RouterApp(App), root));
198
336
  ```
199
337
 
200
- For a client-only app (no SSR), swap `WeftApp.hydrate` for `WeftApp.mount`; everything else is identical.
338
+ `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.
201
339
 
202
340
  ### Link interception
203
341
 
@@ -209,6 +347,11 @@ A plain `h.a({ href })` to a same-origin, route-matching URL performs SPA naviga
209
347
  - same-document (hash-only) navigations
210
348
  - hrefs that don't resolve to a route
211
349
 
350
+ ```typescript
351
+ h.a({ href: "/about" }, "About"); // intercepted: SPA navigation, no reload
352
+ h.a({ href: "/about", target: "_blank" }, "About"); // native: falls through
353
+ ```
354
+
212
355
  You don't wire anything up. `RouterLive` installs the delegated listener for the layer's lifetime and removes it on teardown.
213
356
 
214
357
  ## Programmatic navigation
@@ -265,10 +408,95 @@ On the server, `RouterServer`:
265
408
  - renders `RouterApp` to hydratable HTML inside a **document shell**
266
409
  - reports a status (404 when no route matches or a page raises `RouterNotFound`)
267
410
 
268
- The document shell is itself a `ComponentSlot` that splices the app via `yield* Router.Outlet`, exactly like a layout receives its outlet:
411
+ The document shell is itself a `ComponentSlot` that splices the app via `yield* Router.Outlet`, exactly like a layout receives its outlet.
412
+
413
+ ```typescript
414
+ const { html, status } = await Effect.runPromise(
415
+ RouterServer.render(App, { document: documentShell, url }),
416
+ );
417
+ ```
418
+
419
+ ### Full SSR example
420
+
421
+ 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).
422
+
423
+ ```typescript
424
+ // src/app.ts
425
+ /**
426
+ * Shared, isomorphic router app: three pages under one persistent Shell
427
+ * layout. Side-effect-free: it never mounts or serves. `entry-server.ts`
428
+ * renders the matched route on the server; `entry-client.ts` hydrates over it.
429
+ */
430
+ import { Component, h } from "@weftui/core";
431
+ import { href, notFound, Router } from "@weftui/router";
432
+ import { Schema } from "effect";
433
+
434
+ const idParam = { id: Schema.NumberFromString };
435
+
436
+ export const homeRoute = Router.route("", {
437
+ component: Component.make(() => h.section({ id: "page" }, [h.h2("Home")])),
438
+ });
439
+
440
+ export const aboutRoute = Router.route("about", {
441
+ component: Component.make(() => h.section({ id: "page" }, [h.h2("About")])),
442
+ });
443
+
444
+ export const userRoute = Router.route("users/:id", {
445
+ path: idParam,
446
+ component: ({ path }) => {
447
+ if (!Number.isFinite(path.id) || path.id < 0) return notFound();
448
+ return h.section({ id: "page" }, [h.h2(`User ${path.id}`)]);
449
+ },
450
+ });
451
+
452
+ const Shell = Component.gen(function* () {
453
+ const outlet = yield* Router.Outlet;
454
+ return yield* h.div({ id: "app" }, [
455
+ h.nav([
456
+ h.a({ href: href(homeRoute) }, "Home"),
457
+ " · ",
458
+ h.a({ href: href(aboutRoute) }, "About"),
459
+ ]),
460
+ h.main([outlet]),
461
+ ]);
462
+ });
463
+
464
+ export const App = Router.router(
465
+ Router.layout({ component: Shell }, [homeRoute, aboutRoute, userRoute]),
466
+ { notFound: () => h.section({ id: "page" }, [h.h2("404: page not found")]) },
467
+ );
468
+ ```
469
+
470
+ ```typescript
471
+ // src/entry-client.ts
472
+ /**
473
+ * Client entry: hydrates the server-rendered markup in `#root`.
474
+ *
475
+ * `RouterApp(App)` is the universal router root; `RouterLive(App)` provides
476
+ * the History-API-backed `Router` (seeded from `window.location`, with the
477
+ * same-origin link click interceptor installed). `hydrate` adopts the server
478
+ * DOM in place and resumes the reactive outlet.
479
+ */
480
+ import { WeftApp } from "@weftui/dom/client";
481
+ import { RouterApp, RouterLive } from "@weftui/router/client";
482
+ import { Effect } from "effect";
483
+ import { App } from "./app";
484
+
485
+ const root = document.getElementById("root")!;
486
+
487
+ const app = WeftApp.make(RouterLive(App));
488
+ void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));
489
+ ```
269
490
 
270
491
  ```typescript
271
- // entry-server.ts
492
+ // src/entry-server.ts
493
+ /**
494
+ * Server entry: renders the matched route to a hydratable HTML document.
495
+ *
496
+ * `documentShell` splices the app via `yield* Router.Outlet` (injected per
497
+ * request by `RouterServer`). `render` drives it for a single `url`; `handler`
498
+ * is a Web `fetch`-style handler ready to bridge into Vite or any Web server.
499
+ */
272
500
  import { Component, h } from "@weftui/core";
273
501
  import { Router } from "@weftui/router";
274
502
  import { RouterServer } from "@weftui/router/server";
@@ -287,14 +515,16 @@ const documentShell = Component.gen(function* () {
287
515
  });
288
516
 
289
517
  // { html, status }: `<!DOCTYPE html>` is prepended for you.
290
- export const render = (url: string) =>
518
+ export const render = (url: string): Promise<{ html: string; status: number }> =>
291
519
  Effect.runPromise(RouterServer.render(App, { document: documentShell, url }));
292
520
 
293
- // Or a Web fetch-style handler, ready to bridge into Vite or any Web server.
521
+ // A Web fetch-style handler, ready to bridge into Vite or any Web server.
294
522
  export const handler = RouterServer.toWebHandler(App, { document: documentShell });
295
523
  ```
296
524
 
297
- `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.
525
+ `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).
526
+
527
+ `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.
298
528
 
299
529
  ### `effect/unstable/httpapi` is the spine
300
530
 
@@ -305,27 +535,68 @@ The result is a single `"pages"` group with one GET endpoint per leaf at its ful
305
535
  - **Server**: `RouterServer` dispatches through `HttpApiBuilder` (platform owns request→leaf matching, path/query decode, and the 404 status).
306
536
  - **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.
307
537
 
538
+ ```typescript
539
+ import { Option } from "effect";
540
+
541
+ App.httpApi; // HttpApi.Top: one "pages" group, a GET endpoint per leaf
542
+
543
+ const { httpApiClient } = yield * Router;
544
+ Option.isSome(httpApiClient); // true under RouterLive, false under RouterServer
545
+ ```
546
+
308
547
  ## Errors
309
548
 
310
- | Error | Raised by | Recover with |
311
- | ------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------- |
312
- | `RouterNotFound` | `notFound()`, or no route matched | `Boundary.catchTag("RouterNotFound", …)` (or the app-level `notFound` page) |
313
- | `RouterParamsError` | `Router.params` / `Router.query` on a missing/invalid key or no match | `Boundary.catchTag("RouterParamsError", …)` |
549
+ | Error | Raised by | Recover with |
550
+ | ------------------- | --------------------------------------------------------------------- | --------------------------------------------------------- |
551
+ | `RouterNotFound` | `notFound()`, or no route matched | `Boundary.catchTag(…)` (or the app-level `notFound` page) |
552
+ | `RouterParamsError` | `Router.params` / `Router.query` on a missing/invalid key or no match | `Boundary.catchTag(…)` |
314
553
 
315
554
  Both are modeled as `Schema.TaggedErrorClass`, so they encode/decode across the wire the same way `Boundary.rpc` replays typed failures.
316
555
 
556
+ Recover locally by wrapping just the subtree that can fail, rather than relying on the app-level `notFound` page for everything:
557
+
558
+ ```typescript
559
+ import { Boundary, Component, h } from "@weftui/core";
560
+ import { Router } from "@weftui/router";
561
+
562
+ const UserShell = Component.gen(function* () {
563
+ const outlet = yield* Router.Outlet;
564
+ return yield* h.div({ class: "user" }, [
565
+ Boundary.catchTag(
566
+ {
567
+ tag: "RouterParamsError",
568
+ fallback: () => h.p({ class: "error" }, "Couldn't read this page's params."),
569
+ },
570
+ [outlet],
571
+ ),
572
+ ]);
573
+ });
574
+ ```
575
+
576
+ 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.
577
+
317
578
  ## `Boundary.rpc` interplay
318
579
 
319
580
  Initial SSR navigation works end to end: the server resolves the rpc and inlines its payload, and the client replays it during `hydrate`.
320
581
 
321
582
  **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.
322
583
 
323
- `@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. See the [rpc data boundaries guide](https://weftui.dev/docs/how-to/load-data-with-rpc).
584
+ `@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.
585
+
586
+ ```typescript
587
+ // client (entry-client.ts): network rpc client over the shared group
588
+ const app = WeftApp.make(RouterLive(App, { rpc: { group: StockRpcs } }));
589
+
590
+ // server (entry-server.ts): same group, plus its handler Layer
591
+ const rpc = { group: StockRpcs, handlers: StockLive };
592
+ export const handler = RouterServer.toWebHandler(App, { document: documentShell, rpc });
593
+ ```
324
594
 
325
595
  ## See also
326
596
 
327
597
  - [`@weftui/router` API reference](https://weftui.dev/docs/reference/router)
328
- - [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
598
+ - [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
599
+ - [examples/router-client](https://github.com/stefvw93/weft/tree/main/examples/router-client): the client-only counterpart, no server, no SSR, no `Boundary.rpc`
329
600
  - [Component Authoring](https://weftui.dev/docs/how-to/author-components): `Component.make` / `Component.gen`, the idiomatic way to write route components
330
601
  - [Server-Side Rendering](https://weftui.dev/docs/how-to/render-on-the-server): `renderToStringHydratable`, `hydrate`, and `Boundary.rpc`
331
602
  - [RPC Data Boundaries](https://weftui.dev/docs/how-to/load-data-with-rpc): `Boundary.rpc`, the `Resource` handle, and the four lifecycles