@weftui/core 0.28.0 → 0.29.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.
@@ -7,14 +7,14 @@ description: The Source<A, E, R> vocabulary; Stream, Effect, and Subscribable as
7
7
 
8
8
  # Reactive Primitives
9
9
 
10
- The unified `Source` vocabulary is what lets static values, Effects, Streams, and Subscribables be used interchangeably wherever reactivity is supported — props, children, and style values all accept the same type.
10
+ The unified `Source` vocabulary lets static values, Effects, Streams, and Subscribables be used interchangeably. Props, children, and style values all accept the same type.
11
11
 
12
12
  Weft accepts a `Source` for prop values and children. Any of these is valid wherever reactivity is supported:
13
13
 
14
14
  - A plain static value (`string`, `number`, `boolean`, ...)
15
- - An `Effect.Effect<A, E, R>` runs once and resolves to a value
16
- - A `Stream.Stream<A, E, R>` each emission replaces the previous value
17
- - A `Subscribable<A, E, R>` like a hot stream; already has a "current value"
15
+ - An `Effect.Effect<A, E, R>`: runs once and resolves to a value
16
+ - A `Stream.Stream<A, E, R>`: each emission replaces the previous value
17
+ - A `Subscribable<A, E, R>`: like a hot stream; already has a "current value"
18
18
 
19
19
  The `Source<A, E, R>` type captures this union:
20
20
 
@@ -24,7 +24,7 @@ type Source<A, E, R> = A | Effect.Effect<A, E, R> | Stream.Stream<A, E, R> | Sub
24
24
 
25
25
  ## Static values
26
26
 
27
- Static props behave exactly as you'd expect — set once and never updated:
27
+ Static props are set once and never updated:
28
28
 
29
29
  ```typescript
30
30
  h.div({ class: "container", id: "root" }, "Hello");
@@ -45,17 +45,17 @@ The `E` and `R` channels of the Effect flow into the node's own channels.
45
45
 
46
46
  ## Stream props and children
47
47
 
48
- Streams are the primary reactive primitive. Each emission replaces the previous value in the DOM no diffing, direct DOM update:
48
+ Streams are the primary reactive primitive. Each emission replaces the previous value in the DOM (no diffing, direct DOM update):
49
49
 
50
50
  ```typescript
51
51
  import { SubscriptionRef, Stream } from "effect";
52
52
 
53
53
  const count = yield * SubscriptionRef.make(0);
54
54
 
55
- // SubscriptionRef.changes(count) is a Stream<number> each new value updates the text node
55
+ // SubscriptionRef.changes(count) is a Stream<number>: each new value updates the text node
56
56
  h.span([SubscriptionRef.changes(count)]);
57
57
 
58
- // Stream as a prop each emission sets the attribute
58
+ // Stream as a prop: each emission sets the attribute
59
59
  const isDisabled = Stream.map(SubscriptionRef.changes(count), (n) => n >= 10);
60
60
  h.button({ disabled: isDisabled }, "Submit");
61
61
  ```
@@ -119,7 +119,7 @@ h.div({ style: styleObjectStream });
119
119
 
120
120
  // Combine a whole-object stream with a static property.
121
121
  // A whole-object stream replaces every property on each emit, so fold the
122
- // static value into each emitted object with Stream.map you cannot spread the
122
+ // static value into each emitted object with Stream.map. You cannot spread the
123
123
  // Stream itself into a style object (that copies the Stream's internals, not
124
124
  // its emitted style keys).
125
125
  h.div({
@@ -154,11 +154,11 @@ pipe(
154
154
  );
155
155
  ```
156
156
 
157
- In practice you only encounter `NoPropValue` if you use a finite `Stream` as a prop and it ends before emitting e.g., `Stream.empty` or `Stream.take(0, stream)`. Most usage with `SubscriptionRef.changes` or infinite streams never raises it.
157
+ In practice you only encounter `NoPropValue` when a finite `Stream` prop ends before emitting (e.g., `Stream.empty` or `Stream.take(0, stream)`). Most usage with `SubscriptionRef.changes` or infinite streams never raises it.
158
158
 
159
159
  ## See also
160
160
 
161
- - [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model) streams as the weft woven through a static tree
162
- - [The Combinator API](https://weftui.dev/docs/explanation/combinator-api) how reactive props and children contribute `E`/`R`
163
- - [Style Reactively](https://weftui.dev/docs/how-to/style-reactively) and [Render Keyed Lists](https://weftui.dev/docs/how-to/render-keyed-lists) reactive props and collections in practice
164
- - [`Source` reference](https://weftui.dev/docs/reference/core#source-namespace) the `Source` type and `Source.toSubscribable`
161
+ - [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model): streams as the weft woven through a static tree
162
+ - [The Combinator API](https://weftui.dev/docs/explanation/combinator-api): how reactive props and children contribute `E`/`R`
163
+ - [Style Reactively](https://weftui.dev/docs/how-to/style-reactively) and [Render Keyed Lists](https://weftui.dev/docs/how-to/render-keyed-lists): reactive props and collections in practice
164
+ - [`Source` reference](https://weftui.dev/docs/reference/core#source-namespace): the `Source` type and `Source.toSubscribable`
@@ -2,12 +2,12 @@
2
2
  title: The Rendering Model
3
3
  order: 1
4
4
  section: explanation
5
- description: Why Weft has no virtual DOM nodes are Effects, streams are the live thread woven through a static tree, and hydration adopts server DOM in place.
5
+ description: Why Weft has no virtual DOM. Nodes are Effects, streams are the live thread woven through a static tree, and hydration adopts server DOM in place.
6
6
  ---
7
7
 
8
8
  # The Rendering Model
9
9
 
10
- Weft renders UI by **weaving streams through a static tree**. There is no virtual DOM, no diff, no reconciler comparing two trees each frame. This page explains the model that makes that work — and why it falls out of one definition.
10
+ Weft renders UI by **weaving streams through a static tree**. There is no virtual DOM, no diff, no reconciler comparing two trees each frame.
11
11
 
12
12
  ## Nodes are Effects
13
13
 
@@ -17,38 +17,40 @@ The whole library rests on a single equation:
17
17
  type Node<E = never, R = never> = Effect.Effect<ElementDescriptor, E, R>;
18
18
  ```
19
19
 
20
- Every element in a Weft tree **is an Effect**. `h.div(...)`, a component's return value, a boundary each is an `Effect` that, when run, produces an element descriptor. Two consequences follow immediately, and they shape everything else:
20
+ Every element in a Weft tree **is an Effect**. `h.div(...)`, a component's return value, a boundary: each is an `Effect` that, when run, produces an element descriptor. Two consequences follow:
21
21
 
22
- 1. **The error (`E`) and requirement (`R`) channels accumulate through the tree.** A child that reads a service, or a prop backed by a failible stream, contributes its `R` and `E` to its parent, which contributes to _its_ parent, up to the mount boundary. The type of your app node is the exact union of everything it needs and everything it can fail with visible to the type checker, satisfiable exactly once, at `mount`/`hydrate`. See [The Combinator API](https://weftui.dev/docs/explanation/combinator-api) for how the accumulation works mechanically.
23
- 2. **Every Effect combinator applies to a node directly.** `Effect.provide`, `Effect.flatMap`, `Effect.gen`, `Effect.catch` none of them are special-cased for UI. A node is an ordinary Effect, so the entire Effect ecosystem composes with your view for free.
22
+ 1. **The error (`E`) and requirement (`R`) channels accumulate through the tree.** A child that reads a service, or a prop backed by a failible stream, contributes its `R` and `E` to its parent. That parent contributes to _its_ parent, up to the mount boundary. The type of your app node is the exact union of everything it needs and everything it can fail with. It is visible to the type checker, satisfiable exactly once, at `mount`/`hydrate`. See [The Combinator API](https://weftui.dev/docs/explanation/combinator-api) for how the accumulation works mechanically.
23
+ 2. **Every Effect combinator applies to a node directly.** `Effect.provide`, `Effect.flatMap`, `Effect.gen`, `Effect.catch`: none of them are special-cased for UI. A node is an ordinary Effect, so the entire Effect ecosystem composes with your view for free.
24
24
 
25
- JSX collapses every component to an opaque `JSX.Element`, erasing both channels. Weft keeps them, and that is the point of the whole design. (There is [no JSX](https://weftui.dev/docs/explanation/combinator-api) here components are plain functions you _call_.)
25
+ JSX collapses every component to an opaque `JSX.Element`, erasing both channels. Weft keeps them, and that is the point of the whole design. (There is [no JSX](https://weftui.dev/docs/explanation/combinator-api) here; components are plain functions you _call_.)
26
26
 
27
27
  ## Warp and weft
28
28
 
29
29
  The name is the metaphor. On a loom, the **warp** is the set of fixed threads held under tension; the **weft** is the live thread drawn back and forth across them to form the cloth.
30
30
 
31
- - Your **component tree is the warp** the structure, fixed for the lifetime of a mounted region.
32
- - **Streams are the weft** the live values drawn across that structure. A `Stream`, `Effect`, or `Subscribable` used as a prop value or child is a thread woven through a specific point in the tree.
31
+ - Your **component tree is the warp**: the structure, fixed for the lifetime of a mounted region.
32
+ - **Streams are the weft**: the live values drawn across that structure. A `Stream`, `Effect`, or `Subscribable` used as a prop value or child is a thread woven through a specific point in the tree.
33
33
 
34
- When a stream emits, only the DOM at _that_ point updates. Nothing above it re-runs; no sibling is touched; there is no tree to diff because the structure never changed only a value threaded through one hole in it did. This is why Weft needs no virtual DOM: **the reactivity is local by construction.** The vocabulary of stream-shaped values (and how their channels flow) is [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives).
34
+ When a stream emits, only the DOM at _that_ point updates. Nothing above it re-runs; no sibling is touched. There is no tree to diff because the structure never changed; only a value threaded through one hole in it did. This is why Weft needs no virtual DOM: **the reactivity is local by construction.** The vocabulary of stream-shaped values (and how their channels flow) is [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives).
35
35
 
36
- > **Note.** "Only that point updates" is the default, not a manual optimization. You do not memoize regions or declare dependencies a value is reactive exactly where you thread a stream, and static everywhere else.
36
+ > **Note.** "Only that point updates" is the default, not a manual optimization. You do not memoize regions or declare dependencies. A value is reactive exactly where you thread a stream, and static everywhere else.
37
37
 
38
38
  ## Streams drive all updates
39
39
 
40
- There is no `setState`, no render-triggering scheduler, no "re-render this component." A region of the DOM is live **if and only if** a stream is woven into it. To make something update, you thread a stream through it; to keep something static, you pass a plain value. The renderer subscribes to each woven stream and patches its target in place on every emission — reusing the existing DOM node, patching text and attributes rather than recreating elements (identity, focus, and typed input survive an update).
40
+ There is no `setState`, no render-triggering scheduler, no "re-render this component." A region of the DOM is live **if and only if** a stream is woven into it. To make something update, you thread a stream through it; to keep something static, you pass a plain value.
41
41
 
42
- This also fixes the update _shape_. Because the structure is fixed, an update is always "new value into a known hole," never "reconcile these two trees." Even list rendering — where the number of children genuinely varies is expressed as a keyed region ([`List.each`](https://weftui.dev/docs/how-to/render-keyed-lists)) that reconciles by key rather than by structural diff.
42
+ The renderer subscribes to each woven stream and patches its target in place on every emission. It reuses the existing DOM node and patches text and attributes rather than recreating elements, so identity, focus, and typed input survive an update.
43
+
44
+ This also fixes the update _shape_. Because the structure is fixed, an update is always "new value into a known hole," never "reconcile these two trees." Even list rendering, where the number of children genuinely varies, is expressed as a keyed region ([`List.each`](https://weftui.dev/docs/how-to/render-keyed-lists)). It reconciles by key rather than by structural diff.
43
45
 
44
46
  ## One tree, two sides, hydrate in place
45
47
 
46
48
  The same component tree renders on the server and the client:
47
49
 
48
50
  - On the **server**, the tree renders to an HTML string (or a streaming response) via `@weftui/dom/server`. The _hydratable_ renderers additionally emit the inline data each reactive region needs to resume.
49
- - On the **client**, `WeftApp.hydrate` walks that server-rendered DOM and **adopts it in place** it wires up reactivity and event handlers on the existing nodes rather than re-rendering. The first client production matches the adopted DOM exactly, so nothing is mutated and there is no flash.
51
+ - On the **client**, `WeftApp.hydrate` walks that server-rendered DOM and **adopts it in place**: it wires up reactivity and event handlers on the existing nodes rather than re-rendering. The first client production matches the adopted DOM exactly, so nothing is mutated and there is no flash.
50
52
 
51
- Because the same `Node<E, R>` describes both passes, there is nothing to keep in sync: the server output and the client's first render are the _same tree_ run in two environments. Services flow from the app layer (or the router's render-time context) through the tree to wherever a component reads them, on both sides. The mechanics of the two-sided render live in [Render on the Server](https://weftui.dev/docs/how-to/render-on-the-server); the service flow is [Services and Context](https://weftui.dev/docs/explanation/services-and-context).
53
+ Because the same `Node<E, R>` describes both passes, there is nothing to keep in sync. The server output and the client's first render are the _same tree_ run in two environments. Services flow from the app layer (or the router's render-time context) through the tree to wherever a component reads them, on both sides. The mechanics of the two-sided render live in [Render on the Server](https://weftui.dev/docs/how-to/render-on-the-server); the service flow is [Services and Context](https://weftui.dev/docs/explanation/services-and-context).
52
54
 
53
55
  ## Why this matters
54
56
 
@@ -59,7 +61,7 @@ Because the same `Node<E, R>` describes both passes, there is nothing to keep in
59
61
 
60
62
  ## See also
61
63
 
62
- - [The Combinator API](https://weftui.dev/docs/explanation/combinator-api) how `E`/`R` accumulate; why `Node` is an `Effect`; `h` and components
63
- - [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives) the stream-shaped values you weave through the tree
64
- - [Boundaries and Suspense](https://weftui.dev/docs/explanation/boundaries-and-suspense) how failure and async are modeled as nodes in the same tree
65
- - [Render on the Server](https://weftui.dev/docs/how-to/render-on-the-server) the server/client split and `hydrate`
64
+ - [The Combinator API](https://weftui.dev/docs/explanation/combinator-api): how `E`/`R` accumulate; why `Node` is an `Effect`; `h` and components
65
+ - [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives): the stream-shaped values you weave through the tree
66
+ - [Boundaries and Suspense](https://weftui.dev/docs/explanation/boundaries-and-suspense): how failure and async are modeled as nodes in the same tree
67
+ - [Render on the Server](https://weftui.dev/docs/how-to/render-on-the-server): the server/client split and `hydrate`
@@ -2,16 +2,18 @@
2
2
  title: Services and Context
3
3
  order: 5
4
4
  section: explanation
5
- description: How Effect services reach components the requirement channel, discharging R at the mount, the router's render-time context seam, and ServerTag server-only brands.
5
+ description: "How Effect services reach components: the requirement channel, discharging R at the mount, the router's render-time context seam, and ServerTag server-only brands."
6
6
  ---
7
7
 
8
8
  # Services and Context
9
9
 
10
- Weft has no separate dependency-injection system. It uses Effect's a component that needs a service reads it with `yield* Service`, and because [a node is an Effect](https://weftui.dev/docs/explanation/rendering-model), that requirement rides the node's `R` channel up the tree to a single point where you provide it. This page explains how a service travels from where you provide it to where a component reads it, and the two seams that make that work across the server/client boundary.
10
+ Weft has no separate dependency-injection system. It uses Effect's. A component that needs a service reads it with `yield* Service`. Because [a node is an Effect](https://weftui.dev/docs/explanation/rendering-model), that requirement rides the node's `R` channel up the tree to a single point where you provide it.
11
+
12
+ This page explains how a service travels from provider to reader, and the two seams that make that work across the server/client boundary.
11
13
 
12
14
  ## R accumulates, then discharges once
13
15
 
14
- When a component does `yield* ThemeService`, `ThemeService` enters that node's requirement channel. It accumulates through every parent a boundary, a layout, the app node until the whole tree's `R` is the union of everything any component needs. You satisfy it in **one** place, at the edge:
16
+ When a component does `yield* ThemeService`, `ThemeService` enters that node's requirement channel. It accumulates through every parent: a boundary, a layout, the app node. The whole tree's `R` becomes the union of everything any component needs. You satisfy it in **one** place, at the edge:
15
17
 
16
18
  ```typescript
17
19
  import { Effect } from "effect";
@@ -21,29 +23,37 @@ const app = WeftApp.make(ThemeServiceLive);
21
23
  const handle = WeftApp.mount(app, App(), document.getElementById("root")!);
22
24
  ```
23
25
 
24
- Provide too little and it is a compile error at `WeftApp.make` the type of `App()` names exactly which service is missing. This is the same discipline as any Effect program: `R` is a promise the type checker holds you to, discharged at the program's boundary, not sprinkled through the tree.
26
+ Provide too little and it is a compile error at `WeftApp.make`: the type of `App()` names exactly which service is missing. This is the same discipline as any Effect program. `R` is a promise the type checker holds you to, discharged at the program's boundary, not sprinkled through the tree.
25
27
 
26
- Services flow **down** from the app's layer to every reader, including across reactive boundaries: a stream woven into a prop carries its own `R`, and a handler that reads a service resolves it from the same context. There is no prop-drilling and no context-provider component the requirement channel _is_ the wiring.
28
+ Services flow **down** from the app's layer to every reader, including across reactive boundaries. A stream woven into a prop carries its own `R`, and a handler that reads a service resolves it from the same context. There is no prop-drilling and no context-provider component: the requirement channel _is_ the wiring.
27
29
 
28
30
  ## Layer lifetime and the app runtime
29
31
 
30
- Under the old `mount`/`hydrate` model this was a real footgun. Each call created its own implicit `ManagedRuntime`, and that runtime's effect resolved right after the tree's **initial render** not when the app stopped running — while streams, event handlers, and forked work kept running on it long after. `Effect.provide(scopedLayer)` is `acquireUseRelease` sugar: acquire, run the wrapped effect, then release **when that effect completes**. Wrapped directly around `mount`, the release ran at mount-resolve — while the mounted tree was still reading from the now-disposed service:
32
+ Under the old `mount`/`hydrate` model this was a real footgun. Each call created its own implicit `ManagedRuntime`. That runtime's effect resolved right after the tree's **initial render**, not when the app stopped running. Streams, event handlers, and forked work kept running on it long after.
33
+
34
+ `Effect.provide(scopedLayer)` is `acquireUseRelease` sugar: acquire, run the wrapped effect, then release **when that effect completes**. Wrapped directly around `mount`, the release ran at mount-resolve, while the mounted tree was still reading from the now-disposed service:
31
35
 
32
36
  ```typescript
33
37
  // ❌ (old API) the layer's finalizers ran the instant runPromise settled, while the
34
- // mounted tree kept running every subscription then read a disposed service
38
+ // mounted tree kept running: every subscription then read a disposed service
35
39
  Effect.runPromise(mount(App(), root).pipe(Effect.provide(SomeScopedLayer)));
36
40
  ```
37
41
 
38
- This is exactly what happened with the atom registry layer (`AtomRegistry.layer`, from `effect/unstable/reactivity`) in the [`effect-atom` example](https://github.com/stefvw93/weft/tree/main/examples/effect-atom) (issue #122): every atom-driven region rendered empty, with no error, because the registry the streams read from had already been disposed.
42
+ This is exactly what happened with the atom registry layer (`AtomRegistry.layer`, from `effect/unstable/reactivity`) in the [`effect-atom` example](https://github.com/stefvw93/weft/tree/main/examples/effect-atom) (issue #122). Every atom-driven region rendered empty, with no error, because the registry the streams read from had already been disposed.
43
+
44
+ `WeftApp` closes this gap structurally instead of by convention. An app owns exactly **one** lazy `ManagedRuntime`. `WeftApp.make(layer)` builds the layer on the first `mount`/`hydrate`. It releases only at `WeftApp.dispose(app)`, never when any individual mount's render effect resolves.
45
+
46
+ A scoped layer (`AtomRegistry.layer`, `RouterLive`) therefore just works passed straight to `WeftApp.make`. There is no `mountScoped`, no `Effect.never`, and no manual `ManagedRuntime` composition to reach for.
39
47
 
40
- `WeftApp` closes this gap structurally instead of by convention. An app owns exactly **one** lazy `ManagedRuntime`: `WeftApp.make(layer)` builds the layer on the first `mount`/`hydrate`, and it releases only at `WeftApp.dispose(app)` — never when any individual mount's render effect resolves. A scoped layer (`AtomRegistry.layer`, `RouterLive`) therefore just works passed straight to `WeftApp.make`, with no `mountScoped`, no `Effect.never`, and no manual `ManagedRuntime` composition to reach for. See [Provide Services](https://weftui.dev/docs/how-to/provide-services) for the recipes including `memoMap` sharing across apps and the `Effect.acquireRelease(make, dispose)` pattern for binding an app's own lifetime to an external scope (there is deliberately no `makeScoped`).
48
+ See [Provide Services](https://weftui.dev/docs/how-to/provide-services) for the recipes. They cover `memoMap` sharing across apps and the `Effect.acquireRelease(make, dispose)` pattern for binding an app's own lifetime to an external scope. There is deliberately no `makeScoped`.
41
49
 
42
50
  ## The router's render-time context seam
43
51
 
44
- A plain `WeftApp.mount`/`WeftApp.hydrate` discharges `R` once, at `WeftApp.make`. But under `@weftui/router`, the tree does not render in the context of the effect that called `render` — each request dispatches through platform's HTTP layer in its own managed context, and the reactive outlet drains in the top render context, not in any intermediate node's. Providing a service _ambiently_ around the render would be lost before it reached a route component.
52
+ A plain `WeftApp.mount`/`WeftApp.hydrate` discharges `R` once, at `WeftApp.make`. But under `@weftui/router`, the tree does not render in the context of the effect that called `render`.
45
53
 
46
- So the router exposes an explicit **`context` seam** a `Layer` threaded to the document shell and every route, layout, and leaf:
54
+ Each request dispatches through platform's HTTP layer in its own managed context. The reactive outlet drains in the top render context, not in any intermediate node's. Providing a service _ambiently_ around the render would be lost before it reached a route component.
55
+
56
+ So the router exposes an explicit **`context` seam**, a `Layer` threaded to the document shell and every route, layout, and leaf:
47
57
 
48
58
  ```typescript
49
59
  class Greeting extends Context.Service<Greeting, { text: string }>()("Greeting") {}
@@ -51,17 +61,21 @@ class Greeting extends Context.Service<Greeting, { text: string }>()("Greeting")
51
61
  // server entry
52
62
  RouterServer.render(App, { document, url, context: Layer.succeed(Greeting, { text: "hi" }) });
53
63
 
54
- // client entry same seam, so the hydrated tree reads the same services
64
+ // client entry: same seam, so the hydrated tree reads the same services
55
65
  RouterLive(App, { context: DocsLive });
56
66
  ```
57
67
 
58
- The seam is **symmetric** (same shape on both sides) and **type-tracked**: the def's aggregate residual `R` is discharged here, so a missing provide is a compile error rather than a runtime 500. The residual is `AppServices<R>` the def's `R` minus what the router itself threads (`Router`, `Router.Outlet`, `AppRpcClientTag`). An app with no app-services needs no `context`; a loosely-typed `RouterDef<any, any>` may omit it. This is how the website provides its `Docs` service to every page — see [Add Routing](https://weftui.dev/docs/how-to/add-routing).
68
+ The seam is **symmetric** (same shape on both sides) and **type-tracked**. The def's aggregate residual `R` is discharged here, so a missing provide is a compile error rather than a runtime 500. The residual is `AppServices<R>`: the def's `R` minus what the router itself threads (`Router`, `Router.Outlet`, `AppRpcClientTag`). An app with no app-services needs no `context`; a loosely-typed `RouterDef<any, any>` may omit it.
69
+
70
+ This is how the website provides its `Docs` service to every page. See [Add Routing](https://weftui.dev/docs/how-to/add-routing).
59
71
 
60
72
  ## Server-only services: `ServerTag`
61
73
 
62
- Some services must _never_ run in the browser a database handle, a private credential, an rpc handler's backing store. Declare those with [`ServerTag`](https://weftui.dev/docs/reference/core#servertag) instead of `Context.Service`. It behaves exactly like `Context.Service`, but its identifier carries a **server-only brand**.
74
+ Some services must _never_ run in the browser: a database handle, a private credential, an rpc handler's backing store. Declare those with [`ServerTag`](https://weftui.dev/docs/reference/core#servertag) instead of `Context.Service`. It behaves exactly like `Context.Service`, but its identifier carries a **server-only brand**.
75
+
76
+ The brand's job is to turn a leak into a **compile error at the `hydrate` call site**. A `Boundary.rpc` handler legitimately reads server-only services on the server, but they must not survive into client code. Since `render` only ever touches the _decoded result_ (never the service), a correctly-written boundary keeps its output `R` free of the brand.
63
77
 
64
- The brand's job is to turn a leak into a **compile error at the `hydrate` call site**. A `Boundary.rpc` handler legitimately reads server-only services on the server, but they must not survive into client code: since `render` only ever touches the _decoded result_ (never the service), a correctly-written boundary keeps its output `R` free of the brand. If a branded tag ever leaks into `render` and reaches the client requirement channel, `hydrate`'s `AssertNoServerOnly` resolves `R` to a compile-error sentinel you learn at build time, not from a runtime defect.
78
+ If a branded tag leaks into `render` and reaches the client requirement channel, `hydrate`'s `AssertNoServerOnly` resolves `R` to a compile-error sentinel. You learn at build time, not from a runtime defect.
65
79
 
66
80
  ```typescript
67
81
  import { ServerTag } from "@weftui/core";
@@ -73,15 +87,15 @@ class Db extends ServerTag("Db")<Db, { query: (sql: string) => Effect.Effect<Row
73
87
  ## The whole picture
74
88
 
75
89
  - A component reads a service with `yield* Service`; the requirement enters `R`.
76
- - `R` accumulates through the tree and is discharged **once** at `WeftApp.make`, or through the router's `context` seam.
90
+ - `R` accumulates through the tree and is discharged **once**: at `WeftApp.make`, or through the router's `context` seam.
77
91
  - The same services flow to the same components on the server and the client, because it is the same tree.
78
92
  - `ServerTag` brands the services that must stay server-side, enforced at the `hydrate` boundary.
79
93
 
80
94
  ## See also
81
95
 
82
- - [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model) why services flow through the tree at all
83
- - [The Combinator API](https://weftui.dev/docs/explanation/combinator-api) how `R` accumulates from children and reactive props
84
- - [Provide Services](https://weftui.dev/docs/how-to/provide-services) recipes for app layers, scoped layers, and binding an app's lifetime to an external scope
85
- - [Add Routing](https://weftui.dev/docs/how-to/add-routing) providing app services through the router `context` seam
86
- - [Load Data with RPC](https://weftui.dev/docs/how-to/load-data-with-rpc) where `ServerTag` and the rpc handler Layer meet
96
+ - [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model): why services flow through the tree at all
97
+ - [The Combinator API](https://weftui.dev/docs/explanation/combinator-api): how `R` accumulates from children and reactive props
98
+ - [Provide Services](https://weftui.dev/docs/how-to/provide-services): recipes for app layers, scoped layers, and binding an app's lifetime to an external scope
99
+ - [Add Routing](https://weftui.dev/docs/how-to/add-routing): providing app services through the router `context` seam
100
+ - [Load Data with RPC](https://weftui.dev/docs/how-to/load-data-with-rpc): where `ServerTag` and the rpc handler Layer meet
87
101
  - [`ServerTag` API reference](https://weftui.dev/docs/reference/core#servertag)
@@ -2,15 +2,15 @@
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 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.
14
14
 
15
15
  The package mirrors `@weftui/dom`: a shared (universal) root, a `./client` entry, and a `./server` entry.
16
16
 
@@ -20,21 +20,21 @@ 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 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`.
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
+ You author an **explicit nested route tree** with three namespaced combinators (mirroring the `h.div` / `Component.gen` / `Boundary.catchTag` surface) and seal it once:
26
26
 
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`. |
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`. |
32
32
 
33
33
  The tree is the source of truth. The same sealed `RouterDef` drives both server and client.
34
34
 
35
35
  ## Authoring routes
36
36
 
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.
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.
38
38
 
39
39
  ```typescript
40
40
  import { Component, h } from "@weftui/core";
@@ -57,15 +57,15 @@ const User = Router.route("users/:id", {
57
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
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.
59
59
 
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`.
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`.
61
61
 
62
62
  ## Reading the match: handler-arg props vs. injection
63
63
 
64
- A leaf page reads the current match's decoded `path` / `query` in **either** of two forms.
64
+ A leaf page reads the current match's decoded `path` / `query` in either of two forms.
65
65
 
66
66
  ### Handler-arg props (leaf pages)
67
67
 
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:
68
+ 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
69
 
70
70
  ```typescript
71
71
  const idParam = { id: Schema.NumberFromString };
@@ -80,7 +80,7 @@ Router.route("users/:id/posts", {
80
80
  });
81
81
  ```
82
82
 
83
- This is the most direct form for a leaf. A plain zero-arg thunk works too it just ignores the props.
83
+ This is the most direct form for a leaf. A plain zero-arg thunk works too; it just ignores the props.
84
84
 
85
85
  ### Dependency injection (layouts and deep nodes)
86
86
 
@@ -95,13 +95,15 @@ const UserShell = Component.gen(function* () {
95
95
  });
96
96
  ```
97
97
 
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", …)`.
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. When no route matches, they fail with a tagged [`RouterParamsError`](#errors) carrying `source: "path" | "query"` and the requested `keys`.
99
99
 
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).
100
+ That error bubbles into the app node's aggregate `E`, so a user may recover it with `Boundary.catchTag("RouterParamsError", )`.
101
+
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).
101
103
 
102
104
  ## Layouts and the outlet
103
105
 
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:
106
+ 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
107
 
106
108
  ```typescript
107
109
  const UserShell = Component.gen(function* () {
@@ -113,13 +115,13 @@ const UserShell = Component.gen(function* () {
113
115
  Router.layout({ component: UserShell }, [settingsRoute, postsRoute]);
114
116
  ```
115
117
 
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.
118
+ `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
119
 
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`.
120
+ 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
121
 
120
122
  ### Layout persistence
121
123
 
122
- 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.
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.
123
125
 
124
126
  ## Sealing the tree
125
127
 
@@ -131,11 +133,11 @@ export const App = Router.router(
131
133
  homeRoute,
132
134
  Router.layout({ component: UserShell }, [settingsRoute, postsRoute]),
133
135
  ]),
134
- { notFound: () => h.section({ id: "page" }, [h.h2("404 page not found")]) },
136
+ { notFound: () => h.section({ id: "page" }, [h.h2("404: page not found")]) },
135
137
  );
136
138
  ```
137
139
 
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.
140
+ `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
141
 
140
142
  ## Type-safe links with `href`
141
143
 
@@ -152,11 +154,13 @@ const Home = Component.make(() =>
152
154
  );
153
155
  ```
154
156
 
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).
157
+ 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.
158
+
159
+ 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
160
 
157
161
  ## Not-found
158
162
 
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:
163
+ `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
164
 
161
165
  ```typescript
162
166
  import { notFound, Router } from "@weftui/router";
@@ -171,13 +175,15 @@ Router.route("users/:id", {
171
175
  });
172
176
  ```
173
177
 
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).
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.
175
179
 
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.
180
+ > **`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
181
 
178
182
  ## Client setup
179
183
 
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.
184
+ 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
+
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.
181
187
 
182
188
  ```typescript
183
189
  // entry-client.ts
@@ -191,11 +197,19 @@ const app = WeftApp.make(RouterLive(App));
191
197
  void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));
192
198
  ```
193
199
 
194
- For a client-only app (no SSR), swap `WeftApp.hydrate` for `WeftApp.mount` everything else is identical.
200
+ For a client-only app (no SSR), swap `WeftApp.hydrate` for `WeftApp.mount`; everything else is identical.
195
201
 
196
202
  ### Link interception
197
203
 
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.
204
+ 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:
205
+
206
+ - modified clicks (ctrl/meta/shift/alt or non-left button)
207
+ - `target=_blank` and `download`
208
+ - external origins
209
+ - same-document (hash-only) navigations
210
+ - hrefs that don't resolve to a route
211
+
212
+ You don't wire anything up. `RouterLive` installs the delegated listener for the layer's lifetime and removes it on teardown.
199
213
 
200
214
  ## Programmatic navigation
201
215
 
@@ -230,24 +244,28 @@ yield * setQuery({ sort: "old" }); // replaces the query
230
244
  yield * patchQuery({ sort: "old" }); // merges into the current query
231
245
  ```
232
246
 
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.
247
+ - **`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`.
248
+ - **`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
249
 
236
250
  ### Scroll position on navigation
237
251
 
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.
252
+ 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
253
 
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.
254
+ - **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
255
  - **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.
256
+ - **Hash navigation (`#section`) is unaffected.** It's browser-native, and the link interceptor already lets same-document/hash-only clicks fall through.
243
257
 
244
258
  There's no opt-out; the behavior is hardwired.
245
259
 
246
260
  ## Server setup
247
261
 
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`).
262
+ On the server, `RouterServer`:
249
263
 
250
- The document shell is itself a `ComponentSlot` that splices the app via `yield* Router.Outlet` — exactly like a layout receives its outlet:
264
+ - matches a request URL and builds a fixed-match `Router`
265
+ - renders `RouterApp` to hydratable HTML inside a **document shell**
266
+ - reports a status (404 when no route matches or a page raises `RouterNotFound`)
267
+
268
+ The document shell is itself a `ComponentSlot` that splices the app via `yield* Router.Outlet`, exactly like a layout receives its outlet:
251
269
 
252
270
  ```typescript
253
271
  // entry-server.ts
@@ -268,7 +286,7 @@ const documentShell = Component.gen(function* () {
268
286
  ]);
269
287
  });
270
288
 
271
- // { html, status } `<!DOCTYPE html>` is prepended for you.
289
+ // { html, status }: `<!DOCTYPE html>` is prepended for you.
272
290
  export const render = (url: string) =>
273
291
  Effect.runPromise(RouterServer.render(App, { document: documentShell, url }));
274
292
 
@@ -276,14 +294,16 @@ export const render = (url: string) =>
276
294
  export const handler = RouterServer.toWebHandler(App, { document: documentShell });
277
295
  ```
278
296
 
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.
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.
280
298
 
281
299
  ### `effect/unstable/httpapi` is the spine
282
300
 
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:
301
+ 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`.
302
+
303
+ 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:
284
304
 
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.
305
+ - **Server**: `RouterServer` dispatches through `HttpApiBuilder` (platform owns request→leaf matching, path/query decode, and the 404 status).
306
+ - **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.
287
307
 
288
308
  ## Errors
289
309
 
@@ -296,13 +316,17 @@ Both are modeled as `Schema.TaggedErrorClass`, so they encode/decode across the
296
316
 
297
317
  ## `Boundary.rpc` interplay
298
318
 
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).
319
+ Initial SSR navigation works end to end: the server resolves the rpc and inlines its payload, and the client replays it during `hydrate`.
320
+
321
+ **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
+
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).
300
324
 
301
325
  ## See also
302
326
 
303
327
  - [`@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
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
329
+ - [Component Authoring](https://weftui.dev/docs/how-to/author-components): `Component.make` / `Component.gen`, the idiomatic way to write route components
330
+ - [Server-Side Rendering](https://weftui.dev/docs/how-to/render-on-the-server): `renderToStringHydratable`, `hydrate`, and `Boundary.rpc`
331
+ - [RPC Data Boundaries](https://weftui.dev/docs/how-to/load-data-with-rpc): `Boundary.rpc`, the `Resource` handle, and the four lifecycles
332
+ - [`packages/router/router.specs.md`](https://github.com/stefvw93/weft/blob/main/packages/router/router.specs.md): the full specification