@weftui/router 0.29.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.
@@ -1,5 +1,5 @@
1
- import { S as Router, b as NavState, i as RouterDef, m as RouteNode, u as Fields, x as NavigateOptions } from "../compile-Bb7AknG_.js";
2
- import { n as outletNode, r as HrefArgs, t as RouterApp } from "../outlet-2AnUWKQD.js";
1
+ import { S as Router, b as NavState, i as RouterDef, m as RouteNode, u as Fields, x as NavigateOptions } from "../compile-DFlX4Atp.js";
2
+ import { n as outletNode, r as HrefArgs, t as RouterApp } from "../outlet-CWpiNOxW.js";
3
3
  import { Effect, Layer, Scope } from "effect";
4
4
  import { AppRpcClientTag } from "@weftui/core";
5
5
  import { RpcGroup } from "effect/unstable/rpc";
@@ -1,5 +1,5 @@
1
- import { a as Router, i as setResolvedCommit, n as outletNode, o as getPreload, r as preRunLeaf, t as RouterApp } from "../outlet-BgJmsO_g.js";
2
- import { r as match, t as href } from "../href-3swSSbn_.js";
1
+ import { a as Router, i as setResolvedCommit, n as outletNode, o as getPreload, r as preRunLeaf, t as RouterApp } from "../outlet-Crd9mxZA.js";
2
+ import { r as match, t as href } from "../href-wIrP-_-3.js";
3
3
  import { HttpApiClient } from "effect/unstable/httpapi";
4
4
  import { Context, Effect, Exit, Fiber, Layer, Option, Schema, Scope, Stream, SubscriptionRef } from "effect";
5
5
  import { AppRpcClientTag, Subscribable } from "@weftui/core";
@@ -245,7 +245,7 @@ function pathOf(url) {
245
245
  function applyQuery(transform, options) {
246
246
  return Effect.gen(function* () {
247
247
  const router = yield* Router;
248
- const match = yield* router.currentMatch.get;
248
+ const match = yield* Subscribable.get(router.currentMatch);
249
249
  if (match._tag !== "Matched") return;
250
250
  const next = transform(match.query);
251
251
  const search = encodeSearch(Schema.encodeUnknownSync(match.leaf.querySchema)(next));
@@ -182,7 +182,7 @@ declare const OutletTag_base: Context.ServiceClass<OutletTag, "@weftui/router/Ou
182
182
  declare class OutletTag extends OutletTag_base {}
183
183
  /**
184
184
  * Reads the live match's **path params** for the requested `fields`. Snapshot
185
- * semantics (reads `yield* Router`, then `currentMatch.get`), returning the
185
+ * semantics (reads `yield* Router`, then `Subscribable.get(currentMatch)`), returning the
186
186
  * already-decoded values **directly**: the matcher decoded them against the leaf's
187
187
  * full path schema, so no re-validation is needed. The cast is sound because the
188
188
  * picked subset is the `Type` side of `fields`. Fails with a {@link RouterParamsError}
@@ -198,9 +198,9 @@ declare function readParams<F extends Fields>(fields: F): Effect.Effect<FieldsTy
198
198
  declare function readQuery<F extends Fields>(fields: F): Effect.Effect<FieldsType<F>, RouterParamsError, Router>;
199
199
  /**
200
200
  * Reactive counterpart to {@link readParams}: a {@link Subscribable} of the live
201
- * match's **path params** for `fields`, derived from `currentMatch.changes`. It
201
+ * match's **path params** for `fields`, derived from `Subscribable.changes(currentMatch)`. It
202
202
  * re-emits on every navigation and stays live across `NotFound` (yielding the empty
203
- * subset), so a component can render `[(yield* Router.paramsStream(fields)).changes]`
203
+ * subset), so a component can render `[Subscribable.changes(yield* Router.paramsStream(fields))]`
204
204
  * and update in place even when the outlet keeps the same leaf mounted. Re-exported
205
205
  * as `Router.paramsStream`.
206
206
  */
@@ -1,4 +1,4 @@
1
- import { l as leafRegistry } from "./outlet-BgJmsO_g.js";
1
+ import { l as leafRegistry } from "./outlet-Crd9mxZA.js";
2
2
  import { Result, Schema } from "effect";
3
3
  //#region src/matcher.ts
4
4
  /** Escapes a literal path segment for inclusion in a `RegExp`. */
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- import { A as notFound, C as RouterHttpApiClient, D as RouterNotFound, E as match, O as RouterParamsError, S as Router, T as compileMatchers, _ as TreeE, a as RouterOptions, b as NavState, c as leafRegistry, d as FieldsType, f as LayoutNode, g as SubtreeR, h as SubtreeE, i as RouterDef, k as isRouterNotFound, l as ComponentSlot, m as RouteNode, n as CompiledLayout, o as buildHttpApi, p as RouteHandlerProps, r as CompiledLeaf, s as compile, t as Compiled, u as Fields, v as TreeNode, w as RouteMatch, x as NavigateOptions, y as TreeR } from "./compile-Bb7AknG_.js";
2
- import { i as href, n as outletNode, r as HrefArgs, t as RouterApp } from "./outlet-2AnUWKQD.js";
1
+ import { A as notFound, C as RouterHttpApiClient, D as RouterNotFound, E as match, O as RouterParamsError, S as Router, T as compileMatchers, _ as TreeE, a as RouterOptions, b as NavState, c as leafRegistry, d as FieldsType, f as LayoutNode, g as SubtreeR, h as SubtreeE, i as RouterDef, k as isRouterNotFound, l as ComponentSlot, m as RouteNode, n as CompiledLayout, o as buildHttpApi, p as RouteHandlerProps, r as CompiledLeaf, s as compile, t as Compiled, u as Fields, v as TreeNode, w as RouteMatch, x as NavigateOptions, y as TreeR } from "./compile-DFlX4Atp.js";
2
+ import { i as href, n as outletNode, r as HrefArgs, t as RouterApp } from "./outlet-CWpiNOxW.js";
3
3
  export { type Compiled, type CompiledLayout, type CompiledLeaf, type ComponentSlot, type Fields, type FieldsType, type HrefArgs, type LayoutNode, type NavState, type NavigateOptions, type RouteHandlerProps, type RouteMatch, type RouteNode, Router, RouterApp, type RouterDef, type RouterHttpApiClient, RouterNotFound, type RouterOptions, RouterParamsError, type SubtreeE, type SubtreeR, type TreeE, type TreeNode, type TreeR, buildHttpApi, compile, compileMatchers, href, isRouterNotFound, leafRegistry, match, notFound, outletNode };
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- import { a as Router, c as compile, d as RouterParamsError, f as isRouterNotFound, l as leafRegistry, n as outletNode, p as notFound, s as buildHttpApi, t as RouterApp, u as RouterNotFound } from "./outlet-BgJmsO_g.js";
2
- import { n as compileMatchers, r as match, t as href } from "./href-3swSSbn_.js";
1
+ import { a as Router, c as compile, d as RouterParamsError, f as isRouterNotFound, l as leafRegistry, n as outletNode, p as notFound, s as buildHttpApi, t as RouterApp, u as RouterNotFound } from "./outlet-Crd9mxZA.js";
2
+ import { n as compileMatchers, r as match, t as href } from "./href-wIrP-_-3.js";
3
3
  export { Router, RouterApp, RouterNotFound, RouterParamsError, buildHttpApi, compile, compileMatchers, href, isRouterNotFound, leafRegistry, match, notFound, outletNode };
@@ -1,4 +1,4 @@
1
- import { D as RouterNotFound, S as Router, d as FieldsType, i as RouterDef, m as RouteNode, u as Fields } from "./compile-Bb7AknG_.js";
1
+ import { D as RouterNotFound, S as Router, d as FieldsType, i as RouterDef, m as RouteNode, u as Fields } from "./compile-DFlX4Atp.js";
2
2
  import { Node } from "@weftui/core";
3
3
  //#region src/href.d.ts
4
4
  /**
@@ -301,7 +301,7 @@ function pick(fields, record) {
301
301
  }
302
302
  /**
303
303
  * Reads the live match's **path params** for the requested `fields`. Snapshot
304
- * semantics (reads `yield* Router`, then `currentMatch.get`), returning the
304
+ * semantics (reads `yield* Router`, then `Subscribable.get(currentMatch)`), returning the
305
305
  * already-decoded values **directly**: the matcher decoded them against the leaf's
306
306
  * full path schema, so no re-validation is needed. The cast is sound because the
307
307
  * picked subset is the `Type` side of `fields`. Fails with a {@link RouterParamsError}
@@ -309,7 +309,8 @@ function pick(fields, record) {
309
309
  */
310
310
  function readParams(fields) {
311
311
  return Effect.gen(function* () {
312
- const match = yield* (yield* Router).currentMatch.get;
312
+ const router = yield* Router;
313
+ const match = yield* Subscribable.get(router.currentMatch);
313
314
  if (match._tag === "NotFound") return yield* Effect.fail(new RouterParamsError({
314
315
  source: "path",
315
316
  keys: Object.keys(fields)
@@ -325,7 +326,8 @@ function readParams(fields) {
325
326
  */
326
327
  function readQuery(fields) {
327
328
  return Effect.gen(function* () {
328
- const match = yield* (yield* Router).currentMatch.get;
329
+ const router = yield* Router;
330
+ const match = yield* Subscribable.get(router.currentMatch);
329
331
  if (match._tag === "NotFound") return yield* Effect.fail(new RouterParamsError({
330
332
  source: "query",
331
333
  keys: Object.keys(fields)
@@ -343,15 +345,15 @@ function readQuery(fields) {
343
345
  function selectStream(currentMatch, fields, source) {
344
346
  const select = (m) => pick(fields, m._tag === "Matched" ? m[source] : {});
345
347
  return Subscribable.make({
346
- get: Effect.map(currentMatch.get, select),
347
- changes: Stream.map(currentMatch.changes, select)
348
+ get: Effect.map(Subscribable.get(currentMatch), select),
349
+ changes: Stream.map(Subscribable.changes(currentMatch), select)
348
350
  });
349
351
  }
350
352
  /**
351
353
  * Reactive counterpart to {@link readParams}: a {@link Subscribable} of the live
352
- * match's **path params** for `fields`, derived from `currentMatch.changes`. It
354
+ * match's **path params** for `fields`, derived from `Subscribable.changes(currentMatch)`. It
353
355
  * re-emits on every navigation and stays live across `NotFound` (yielding the empty
354
- * subset), so a component can render `[(yield* Router.paramsStream(fields)).changes]`
356
+ * subset), so a component can render `[Subscribable.changes(yield* Router.paramsStream(fields))]`
355
357
  * and update in place even when the outlet keeps the same leaf mounted. Re-exported
356
358
  * as `Router.paramsStream`.
357
359
  */
@@ -370,7 +372,7 @@ function subscribeQuery(fields) {
370
372
  }
371
373
  /**
372
374
  * Reactive {@link Subscribable} of the client {@link NavState}. A component reads
373
- * `[(yield* Router.navigatingStream).changes]` to render pending UI during a
375
+ * `[Subscribable.changes(yield* Router.navigatingStream)]` to render pending UI during a
374
376
  * deferred-commit navigation (`pending-navigation.specs.md`,
375
377
  * `resolve-before-commit.specs.md`). Re-exported as `Router.navigatingStream`.
376
378
  */
@@ -436,10 +438,10 @@ function takeResolvedCommit(router, url) {
436
438
  }
437
439
  /**
438
440
  * The staged `Router` view the pre-run executes under (AC-R4): identical to
439
- * `router` except `currentMatch.get` resolves to the **target** match. The URL
441
+ * `router` except `Subscribable.get(currentMatch)` resolves to the **target** match. The URL
440
442
  * ref has not moved yet, and one-shot reads (`Router.params`, `Router.query`,
441
- * `currentMatch.get`) inside the pre-running component body must decode the
442
- * destination, not the page being left. `currentMatch.changes` (and `navigate` /
443
+ * `Subscribable.get(currentMatch)`) inside the pre-running component body must decode the
444
+ * destination, not the page being left. `Subscribable.changes(currentMatch)` (and `navigate` /
443
445
  * `httpApiClient` / `navigating`) delegate to the live service, so reactive
444
446
  * subscriptions, which occur at render/mount time post-commit, observe the
445
447
  * committed match onward.
@@ -449,7 +451,7 @@ function stageMatch(router, target) {
449
451
  ...router,
450
452
  currentMatch: Subscribable.make({
451
453
  get: Effect.succeed(target),
452
- changes: router.currentMatch.changes
454
+ changes: Subscribable.changes(router.currentMatch)
453
455
  })
454
456
  };
455
457
  }
@@ -524,7 +526,7 @@ function renderLevel(def, router, index, match) {
524
526
  * reactive child region.
525
527
  */
526
528
  function levelStream(def, router, index) {
527
- return pipe(router.currentMatch.changes, Stream.map((match) => [keyOf(index, match), match]), Stream.changesWith((a, b) => a[0] === b[0]), Stream.map(([, match]) => renderLevel(def, router, index, match)));
529
+ return pipe(Subscribable.changes(router.currentMatch), Stream.map((match) => [keyOf(index, match), match]), Stream.changesWith((a, b) => a[0] === b[0]), Stream.map(([, match]) => renderLevel(def, router, index, match)));
528
530
  }
529
531
  /**
530
532
  * The bare nested-outlet node for a router definition: a fragment whose single
@@ -1,4 +1,4 @@
1
- import { S as Router, i as RouterDef, l as ComponentSlot } from "../compile-Bb7AknG_.js";
1
+ import { S as Router, i as RouterDef, l as ComponentSlot } from "../compile-DFlX4Atp.js";
2
2
  import { Effect, Layer } from "effect";
3
3
  import { AppRpcClientTag } from "@weftui/core";
4
4
  import { RpcGroup } from "effect/unstable/rpc";
@@ -1,4 +1,4 @@
1
- import { a as Router, f as isRouterNotFound, n as outletNode, u as RouterNotFound } from "../outlet-BgJmsO_g.js";
1
+ import { a as Router, f as isRouterNotFound, n as outletNode, u as RouterNotFound } from "../outlet-Crd9mxZA.js";
2
2
  import { HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
3
3
  import { Cause, Effect, Exit, Layer, Option, Schema, Scope, Stream } from "effect";
4
4
  import { AppRpcClientTag, Subscribable } from "@weftui/core";
@@ -81,13 +81,13 @@ On the server, `renderToStreamHydratable` emits the fallback inline and appends
81
81
  Conceptually it is the same idea as the other boundaries: a node that decides what renders in a subtree. But the thing it intercepts is a **round-trip to a server handler**. Instead of a children array, it takes a `render` function that receives a reactive [`Resource`](https://weftui.dev/docs/reference/core#resourcea).
82
82
 
83
83
  ```typescript
84
- import { Boundary, h } from "@weftui/core";
84
+ import { Boundary, h, Subscribable } from "@weftui/core";
85
85
  import { Stream } from "effect";
86
86
 
87
87
  Boundary.rpc(
88
88
  GetStock,
89
89
  () => ({ id: productId }),
90
- (resource) => h.span([Stream.map(resource.value.changes, (s) => String(s.units))]),
90
+ (resource) => h.span([Stream.map(Subscribable.changes(resource.value), (s) => String(s.units))]),
91
91
  { fallback: h.p("loading…") },
92
92
  );
93
93
  ```
@@ -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"`).
59
73
 
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`.
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.
75
+
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(…)`.
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
+ ```
101
137
 
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).
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,103 @@ 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)).
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
+ ```
187
320
 
188
321
  ```typescript
189
- // 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
+ */
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
- const root = document.getElementById("root")!;
332
+ const root = document.getElementById("root");
333
+ if (root === null) {
334
+ throw new Error("#root not found");
335
+ }
336
+
196
337
  const app = WeftApp.make(RouterLive(App));
197
- void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));
338
+ void Effect.runPromise(WeftApp.mount(app, RouterApp(App), root));
198
339
  ```
199
340
 
200
- 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.
201
342
 
202
343
  ### Link interception
203
344
 
@@ -209,6 +350,11 @@ A plain `h.a({ href })` to a same-origin, route-matching URL performs SPA naviga
209
350
  - same-document (hash-only) navigations
210
351
  - hrefs that don't resolve to a route
211
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
+
212
358
  You don't wire anything up. `RouterLive` installs the delegated listener for the layer's lifetime and removes it on teardown.
213
359
 
214
360
  ## Programmatic navigation
@@ -265,10 +411,98 @@ On the server, `RouterServer`:
265
411
  - renders `RouterApp` to hydratable HTML inside a **document shell**
266
412
  - reports a status (404 when no route matches or a page raises `RouterNotFound`)
267
413
 
268
- The document shell is itself a `ComponentSlot` that splices the app via `yield* Router.Outlet`, exactly like a layout receives its outlet:
414
+ The document shell is itself a `ComponentSlot` that splices the app via `yield* Router.Outlet`, exactly like a layout receives its outlet.
415
+
416
+ ```typescript
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
+ ```
269
496
 
270
497
  ```typescript
271
- // entry-server.ts
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
+ */
272
506
  import { Component, h } from "@weftui/core";
273
507
  import { Router } from "@weftui/router";
274
508
  import { RouterServer } from "@weftui/router/server";
@@ -287,14 +521,16 @@ const documentShell = Component.gen(function* () {
287
521
  });
288
522
 
289
523
  // { html, status }: `<!DOCTYPE html>` is prepended for you.
290
- export const render = (url: string) =>
524
+ export const render = (url: string): Promise<{ html: string; status: number }> =>
291
525
  Effect.runPromise(RouterServer.render(App, { document: documentShell, url }));
292
526
 
293
- // 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.
294
528
  export const handler = RouterServer.toWebHandler(App, { document: documentShell });
295
529
  ```
296
530
 
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.
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.
298
534
 
299
535
  ### `effect/unstable/httpapi` is the spine
300
536
 
@@ -305,27 +541,68 @@ The result is a single `"pages"` group with one GET endpoint per leaf at its ful
305
541
  - **Server**: `RouterServer` dispatches through `HttpApiBuilder` (platform owns request→leaf matching, path/query decode, and the 404 status).
306
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.
307
543
 
544
+ ```typescript
545
+ import { Option } from "effect";
546
+
547
+ App.httpApi; // HttpApi.Top: one "pages" group, a GET endpoint per leaf
548
+
549
+ const { httpApiClient } = yield * Router;
550
+ Option.isSome(httpApiClient); // true under RouterLive, false under RouterServer
551
+ ```
552
+
308
553
  ## Errors
309
554
 
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", …)` |
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(…)` |
314
559
 
315
560
  Both are modeled as `Schema.TaggedErrorClass`, so they encode/decode across the wire the same way `Boundary.rpc` replays typed failures.
316
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
+
317
584
  ## `Boundary.rpc` interplay
318
585
 
319
586
  Initial SSR navigation works end to end: the server resolves the rpc and inlines its payload, and the client replays it during `hydrate`.
320
587
 
321
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.
322
589
 
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).
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
+ ```
324
600
 
325
601
  ## See also
326
602
 
327
603
  - [`@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
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`
329
606
  - [Component Authoring](https://weftui.dev/docs/how-to/author-components): `Component.make` / `Component.gen`, the idiomatic way to write route components
330
607
  - [Server-Side Rendering](https://weftui.dev/docs/how-to/render-on-the-server): `renderToStringHydratable`, `hydrate`, and `Boundary.rpc`
331
608
  - [RPC Data Boundaries](https://weftui.dev/docs/how-to/load-data-with-rpc): `Boundary.rpc`, the `Resource` handle, and the four lifecycles
@@ -193,13 +193,13 @@ When a caller passes a plain string, the component's node type has `never` for t
193
193
  Splicing a `Source` straight into `h` (`[props.label]`) is enough when you only place it in the tree. When the body needs to **read or derive** from the value (combine two props, feed a stream operator, drive logic), normalize it first with [`Source.toSubscribable`](https://weftui.dev/docs/reference/core#sourcetosubscribablesource-key). It turns any `Source<A>` into an await-first, hot `Subscribable<A>`:
194
194
 
195
195
  ```typescript
196
- import { Component, h, Source } from "@weftui/core";
196
+ import { Component, h, Source, Subscribable } from "@weftui/core";
197
197
  import { Stream } from "effect";
198
198
 
199
199
  const LoudLabel = Component.gen(function* (props: { label: Source.Source<string> }) {
200
200
  const label = yield* Source.toSubscribable(props.label); // Subscribable<string>
201
201
  // Now derive from it like any Subscribable: static, Effect, and Stream inputs all work.
202
- return yield* h.strong([Stream.map(label.changes, (text) => text.toUpperCase())]);
202
+ return yield* h.strong([Stream.map(Subscribable.changes(label), (text) => text.toUpperCase())]);
203
203
  });
204
204
  ```
205
205
 
@@ -14,7 +14,7 @@ description: Boundary.rpc, server-resolved and client-refreshable data; the cont
14
14
  A `Boundary.rpc` is a **thin consumer**. It carries an rpc, a payload thunk, and a `render` that receives a reactive [`Resource`](https://weftui.dev/docs/reference/core#resourcea). The renderer resolves the rpc through the ambient [`AppRpcClientTag`](https://weftui.dev/docs/reference/core#apprpcclienttag) seam (provided by `@weftui/router`). The same rpc serves every lifecycle, so SSR-replay, refetch, and client-first mount are one mechanism, not three.
15
15
 
16
16
  ```typescript
17
- import { Boundary, h } from "@weftui/core";
17
+ import { Boundary, h, Subscribable } from "@weftui/core";
18
18
  import { Stream } from "effect";
19
19
  import { GetStock } from "./data/inventory";
20
20
 
@@ -26,7 +26,7 @@ Boundary.rpc(
26
26
  ) =>
27
27
  h.p([
28
28
  "in stock: ",
29
- h.span([Stream.map(resource.value.changes, (s) => String(s.units))]),
29
+ h.span([Stream.map(Subscribable.changes(resource.value), (s) => String(s.units))]),
30
30
  h.button({ type: "button", onclick: () => resource.refetch }, "Refresh"),
31
31
  ]),
32
32
  { fallback: h.p("loading stock…") }, // shown only on a client-first mount
@@ -127,8 +127,8 @@ Because the SSR path seeds `value` await-first (it emits the seed immediately),
127
127
  ```typescript
128
128
  (resource) =>
129
129
  h.section({ class: "product" }, [
130
- h.span([Stream.map(resource.value.changes, (s) => String(s.units))]),
131
- h.span([Stream.map(resource.pending.changes, (p) => (p ? "refreshing…" : ""))]),
130
+ h.span([Stream.map(Subscribable.changes(resource.value), (s) => String(s.units))]),
131
+ h.span([Stream.map(Subscribable.changes(resource.pending), (p) => (p ? "refreshing…" : ""))]),
132
132
  h.button({ type: "button", onclick: () => resource.refetch }, "Refresh stock"),
133
133
  ]);
134
134
  ```
@@ -12,14 +12,14 @@ description: Render a reactive collection with List.each so reordering, insertin
12
12
  Use [`List.each`](https://weftui.dev/docs/reference/core#listeach), the keyed-list combinator. It renders each item **once per key** and reconciles across emissions. A reorder _moves_ existing DOM nodes, an insert adds one, a remove drops one, and untouched rows are left entirely alone.
13
13
 
14
14
  ```typescript
15
- import { h, List } from "@weftui/core";
15
+ import { h, List, Subscribable } from "@weftui/core";
16
16
  import { Stream } from "effect";
17
17
 
18
18
  declare const rows: Subscribable.Subscribable<ReadonlyArray<{ id: number; name: string }>>;
19
19
 
20
20
  h.ul([
21
21
  List.each(
22
- { of: rows.changes, by: (row) => row.id }, // key by stable identity
22
+ { of: Subscribable.changes(rows), by: (row) => row.id }, // key by stable identity
23
23
  (row) => h.li(row.name),
24
24
  ),
25
25
  ]);
@@ -30,7 +30,7 @@ h.ul([
30
30
 
31
31
  ## Why not `map`?
32
32
 
33
- Mapping items by hand (`Stream.map(rows.changes, (rs) => rs.map(r => h.li(r.name)))`) produces a **new children array on every emission**. The renderer then rebuilds the whole region: every row's DOM node is recreated even if only one item moved.
33
+ Mapping items by hand (`Stream.map(Subscribable.changes(rows), (rs) => rs.map(r => h.li(r.name)))`) produces a **new children array on every emission**. The renderer then rebuilds the whole region: every row's DOM node is recreated even if only one item moved.
34
34
 
35
35
  `List.each` reconciles by key instead, so DOM identity (and the focus/scroll/typed-input state attached to it) survives across updates.
36
36
 
@@ -39,8 +39,8 @@ Mapping items by hand (`Stream.map(rows.changes, (rs) => rs.map(r => h.li(r.name
39
39
  Because `render` runs **exactly once per key**, reconciliation never re-runs it for a kept row, so it never refreshes that row's content on its own. To make a row's content reactive, thread a `Stream` **inside** the row rather than expecting a re-render:
40
40
 
41
41
  ```typescript
42
- List.each({ of: rows.changes, by: (row) => row.id }, (row) =>
43
- h.li([h.span([Stream.map(row.status.changes, (s) => s)])]),
42
+ List.each({ of: Subscribable.changes(rows), by: (row) => row.id }, (row) =>
43
+ h.li([h.span([Stream.map(Subscribable.changes(row.status), (s) => s)])]),
44
44
  );
45
45
  ```
46
46
 
@@ -54,7 +54,7 @@ Use a hydratable renderer whenever the client will call `hydrate`. The plain ren
54
54
  It follows the same server/client split: the rpc **contract** (pure Schema) is shared, while its **handler** lives in a server-only Layer the client never imports.
55
55
 
56
56
  ```typescript
57
- import { Boundary, h } from "@weftui/core";
57
+ import { Boundary, h, Subscribable } from "@weftui/core";
58
58
  import { Stream } from "effect";
59
59
  import { GetStock } from "./data/inventory";
60
60
 
@@ -65,7 +65,7 @@ const StockPanel = (productId: number) =>
65
65
  (resource) =>
66
66
  h.p([
67
67
  "in stock: ",
68
- h.span([Stream.map(resource.value.changes, (stock) => String(stock.units))]),
68
+ h.span([Stream.map(Subscribable.changes(resource.value), (stock) => String(stock.units))]),
69
69
  h.button({ type: "button", onclick: () => resource.refetch }, "Refresh"),
70
70
  ]),
71
71
  { fallback: h.p("loading stock…") }, // shown only on a client-first SPA mount
@@ -14,7 +14,7 @@ When you navigate to a route, the router is **deferred-commit**. It resolves the
14
14
  That resolve window is exposed as a reactive signal, [`Router.navigating`](https://weftui.dev/docs/reference/router#routernavigating), that you read to render pending UI.
15
15
 
16
16
  ```typescript
17
- import { Component, h } from "@weftui/core";
17
+ import { Component, h, Subscribable } from "@weftui/core";
18
18
  import { Router } from "@weftui/router";
19
19
  import { Stream } from "effect";
20
20
 
@@ -25,7 +25,7 @@ const Shell = Component.gen(function* () {
25
25
  h.div({
26
26
  id: "nav-progress",
27
27
  "aria-hidden": "true",
28
- class: Stream.map(nav.changes, (s) =>
28
+ class: Stream.map(Subscribable.changes(nav), (s) =>
29
29
  s._tag === "Navigating" ? "nav-progress is-navigating" : "nav-progress",
30
30
  ),
31
31
  }),
@@ -358,9 +358,9 @@ The keyed-list combinator. It is the opt-in alternative to wholesale child rebui
358
358
  > elements.
359
359
 
360
360
  ```typescript
361
- import { h, List } from "@weftui/core";
361
+ import { h, List, Subscribable } from "@weftui/core";
362
362
 
363
- h.ul([List.each({ of: rows.changes, by: (row) => row.id }, (row) => h.li(row.name))]);
363
+ h.ul([List.each({ of: Subscribable.changes(rows), by: (row) => row.id }, (row) => h.li(row.name))]);
364
364
  ```
365
365
 
366
366
  #### `List.each`
@@ -111,7 +111,7 @@ Router.params<F extends Fields>(fields: F): Effect<FieldsType<F>, RouterParamsEr
111
111
  Router.query<F extends Fields>(fields: F): Effect<FieldsType<F>, RouterParamsError, Router>;
112
112
  ```
113
113
 
114
- Snapshot accessors that read the **live match** (`currentMatch.get`) and pick the requested `fields` keys from the decoded path/query. The matcher already decoded the values against the leaf's full schema, so they are returned directly (no re-validation).
114
+ Snapshot accessors that read the **live match** (`Subscribable.get(currentMatch)`) and pick the requested `fields` keys from the decoded path/query. The matcher already decoded the values against the leaf's full schema, so they are returned directly (no re-validation).
115
115
 
116
116
  Readable from **any** component, not just the leaf: this is the dependency-injection path layouts and deep nodes use (leaves can instead take [handler-arg props](#routerroute)). They fail with a [`RouterParamsError`](#routerparamserror) (`source: "path" | "query"`, plus the requested `keys`) when no route matches.
117
117
 
@@ -122,7 +122,7 @@ Router.paramsStream<F extends Fields>(fields: F): Effect<Subscribable<FieldsType
122
122
  Router.queryStream<F extends Fields>(fields: F): Effect<Subscribable<FieldsType<F>>, never, Router>;
123
123
  ```
124
124
 
125
- The **reactive** counterparts. Each resolves a `Subscribable<FieldsType<F>>` derived from `currentMatch.changes`. A component can render `[(yield* Router.queryStream(fields)).changes]` and update **in place** even when the outlet keeps the same leaf mounted. That is exactly the query-only case (`setQuery` / `patchQuery`) a snapshot `Router.query` would miss.
125
+ The **reactive** counterparts. Each resolves a `Subscribable<FieldsType<F>>` derived from `Subscribable.changes(currentMatch)`. A component can render `[Subscribable.changes(yield* Router.queryStream(fields))]` and update **in place** even when the outlet keeps the same leaf mounted. That is exactly the query-only case (`setQuery` / `patchQuery`) a snapshot `Router.query` would miss.
126
126
 
127
127
  Resilient across navigations: a `NotFound` match yields the empty subset rather than failing, so the stream stays live.
128
128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weftui/router",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Universal nested router for Weft: one route tree, server and client, with type-safe params and href",
5
5
  "keywords": [
6
6
  "effect",
@@ -44,8 +44,8 @@
44
44
  "access": "public"
45
45
  },
46
46
  "dependencies": {
47
- "@weftui/core": "0.29.0",
48
- "@weftui/dom": "0.29.0"
47
+ "@weftui/core": "0.30.0",
48
+ "@weftui/dom": "0.30.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/jsdom": "^28.0.3",