@weftui/dom 0.26.0 → 0.26.2

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.
@@ -0,0 +1,65 @@
1
+ ---
2
+ title: The Rendering Model
3
+ order: 1
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.
6
+ ---
7
+
8
+ # The Rendering Model
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.
11
+
12
+ ## Nodes are Effects
13
+
14
+ The whole library rests on a single equation:
15
+
16
+ ```typescript
17
+ type Node<E = never, R = never> = Effect.Effect<ElementDescriptor, E, R>;
18
+ ```
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:
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.catchAll` — 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
+
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
+
27
+ ## Warp and weft
28
+
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
+
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
+
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
+
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
+
38
+ ## Streams drive all updates
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).
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.
43
+
44
+ ## One tree, two sides, hydrate in place
45
+
46
+ The same component tree renders on the server and the client:
47
+
48
+ - 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**, `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
+
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 mount (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
+
53
+ ## Why this matters
54
+
55
+ - **No diff cost.** Updates are O(changed value), not O(tree). There is no reconciliation pass to pay for.
56
+ - **Local reasoning.** A stream woven at one point cannot affect another. What is reactive is exactly what you made reactive.
57
+ - **Type-honest edges.** The app node's `E`/`R` is the whole app's error and dependency surface, checked at compile time and discharged once at the edge.
58
+ - **Flash-free SSR by construction.** Hydration adopts rather than replaces, because the tree is identical on both sides.
59
+
60
+ ## See also
61
+
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`
@@ -0,0 +1,91 @@
1
+ ---
2
+ title: Services and Context
3
+ order: 5
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.
6
+ ---
7
+
8
+ # Services and Context
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.
11
+
12
+ ## R accumulates, then discharges once
13
+
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:
15
+
16
+ ```typescript
17
+ import { Effect } from "effect";
18
+ import { mount } from "@weftui/dom/client";
19
+
20
+ const handle = pipe(
21
+ mount(App(), document.getElementById("root")!),
22
+ Effect.provide(ThemeServiceLive),
23
+ );
24
+ ```
25
+
26
+ Provide too little and it is a compile error at the mount call — 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.
27
+
28
+ Services flow **down** from that provide point 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.
29
+
30
+ ## Layer lifetime at the mount
31
+
32
+ The `ThemeServiceLive` example above works because `mount`'s effect and the service's lifetime coincide by accident: `ThemeServiceLive` is a plain value layer with nothing to release, so it makes no difference whether it is "alive" for one tick or the whole session. That accident stops holding the moment the layer is **scoped** — built with `Layer.scoped`, backed by an `acquireRelease` — because `mount`'s effect resolves right after the tree's **initial render**, not when the app stops running. Streams, event handlers, and forked work all keep running on the mount's runtime long after that Effect has settled.
33
+
34
+ `Effect.provide(scopedLayer)` is `acquireUseRelease` sugar: acquire, run the wrapped effect, then release **when that effect completes**. Wrap it directly around `mount`, and the release runs at mount-resolve — while the mounted tree is still reading from the now-disposed service:
35
+
36
+ ```typescript
37
+ // ❌ the layer's finalizers run the instant runPromise settles, while the
38
+ // mounted tree keeps running — every subscription now reads a disposed service
39
+ Effect.runPromise(mount(App(), root).pipe(Effect.provide(SomeScopedLayer)));
40
+ ```
41
+
42
+ This is exactly what happened with effect-atom's `Registry.layer` 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
+ The fix is to give the scoped layer a lifetime that matches the app, not the initial render: provide it **outside** a scoped region that stays open for as long as the app should run, and mount inside that region with `mountScoped` (which ties `unmount` to the region's scope instead of to the resolution of the mount effect). An `Effect.never` (or `Deferred.await` on a shutdown signal) keeps the region — and therefore the layer — alive until something explicitly closes it. See [Provide Services](https://weftui.dev/docs/how-to/provide-services) for the recipe, including the `ManagedRuntime` alternative when a scoped region isn't a good fit.
45
+
46
+ ## The router's render-time context seam
47
+
48
+ A plain `mount`/`hydrate` discharges `R` at the call site. 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.
49
+
50
+ So the router exposes an explicit **`context` seam** — a `Layer` threaded to the document shell and every route, layout, and leaf:
51
+
52
+ ```typescript
53
+ class Greeting extends Context.Tag("Greeting")<Greeting, { text: string }>() {}
54
+
55
+ // server entry
56
+ RouterServer.render(App, { document, url, context: Layer.succeed(Greeting, { text: "hi" }) });
57
+
58
+ // client entry — same seam, so the hydrated tree reads the same services
59
+ RouterLive(App, { context: DocsLive });
60
+ ```
61
+
62
+ 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).
63
+
64
+ ## Server-only services: `ServerTag`
65
+
66
+ 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.Tag`. It behaves exactly like `Context.Tag`, but its identifier carries a **server-only brand**.
67
+
68
+ 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.
69
+
70
+ ```typescript
71
+ import { ServerTag } from "@weftui/core";
72
+
73
+ // Only ever provided on the server; a leak into client code fails to compile.
74
+ class Db extends ServerTag("Db")<Db, { query: (sql: string) => Effect.Effect<Row[]> }>() {}
75
+ ```
76
+
77
+ ## The whole picture
78
+
79
+ - A component reads a service with `yield* Service`; the requirement enters `R`.
80
+ - `R` accumulates through the tree and is discharged **once** — at `mount`/`hydrate`, or through the router's `context` seam.
81
+ - The same services flow to the same components on the server and the client, because it is the same tree.
82
+ - `ServerTag` brands the services that must stay server-side, enforced at the `hydrate` boundary.
83
+
84
+ ## See also
85
+
86
+ - [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model) — why services flow through the tree at all
87
+ - [The Combinator API](https://weftui.dev/docs/explanation/combinator-api) — how `R` accumulates from children and reactive props
88
+ - [Provide Services](https://weftui.dev/docs/how-to/provide-services) — recipes for value layers, scoped layers with `mountScoped`, and `ManagedRuntime`
89
+ - [Add Routing](https://weftui.dev/docs/how-to/add-routing) — providing app services through the router `context` seam
90
+ - [Load Data with RPC](https://weftui.dev/docs/how-to/load-data-with-rpc) — where `ServerTag` and the rpc handler Layer meet
91
+ - [`ServerTag` API reference](https://weftui.dev/docs/reference/core#servertag)
@@ -0,0 +1,296 @@
1
+ ---
2
+ title: Routing
3
+ order: 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."
6
+ ---
7
+
8
+ # Routing
9
+
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
+
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
+
15
+ The package mirrors `@weftui/dom`: a shared (universal) root, a `./client` entry, and a `./server` entry.
16
+
17
+ ```bash
18
+ npm install @weftui/router
19
+ ```
20
+
21
+ ## The mental model
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`.
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:
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`. |
32
+
33
+ The tree is the source of truth. The same sealed `RouterDef` drives both server and client.
34
+
35
+ ## Authoring routes
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.
38
+
39
+ ```typescript
40
+ import { Component, h } from "@weftui/core";
41
+ import { Router } from "@weftui/router";
42
+ import { Schema } from "effect";
43
+
44
+ const About = Router.route("about", {
45
+ component: Component.make(() => h.h1("About")),
46
+ });
47
+
48
+ const User = Router.route("users/:id", {
49
+ path: { id: Schema.NumberFromString },
50
+ component: Component.gen(function* () {
51
+ const { id } = yield* Router.params({ id: Schema.NumberFromString });
52
+ return yield* h.div(`User ${id}`);
53
+ }),
54
+ });
55
+ ```
56
+
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.
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`.
61
+
62
+ ## Reading the match: handler-arg props vs. injection
63
+
64
+ A leaf page reads the current match's decoded `path` / `query` in **either** of two forms.
65
+
66
+ ### Handler-arg props (leaf pages)
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:
69
+
70
+ ```typescript
71
+ const idParam = { id: Schema.NumberFromString };
72
+ const sortQuery = { sort: Schema.optional(Schema.String) };
73
+
74
+ Router.route("users/:id/posts", {
75
+ path: idParam,
76
+ query: sortQuery,
77
+ // `path.id` is already a number; `query.sort` is `string | undefined`.
78
+ component: ({ path, query }) =>
79
+ h.section([h.h2(`Posts for user ${path.id}`), h.p(`sort: ${query.sort ?? "none"}`)]),
80
+ });
81
+ ```
82
+
83
+ This is the most direct form for a leaf. A plain zero-arg thunk works too — it just ignores the props.
84
+
85
+ ### Dependency injection (layouts and deep nodes)
86
+
87
+ A **layout** sits above the leaf and so can't take handler args; it reads the match by **dependency injection** instead. `Router.params(fields)` / `Router.query(fields)` are readable from **any** component:
88
+
89
+ ```typescript
90
+ // A /users/:id layout (above the leaf) reads `:id` by injection.
91
+ const UserShell = Component.gen(function* () {
92
+ const { id } = yield* Router.params(idParam);
93
+ const outlet = yield* Router.Outlet;
94
+ return yield* h.div({ class: "user" }, [h.h1(`User ${id}`), outlet]);
95
+ });
96
+ ```
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", …)`.
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).
101
+
102
+ ## Layouts and the outlet
103
+
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:
105
+
106
+ ```typescript
107
+ const UserShell = Component.gen(function* () {
108
+ const { id } = yield* Router.params(idParam);
109
+ const outlet = yield* Router.Outlet;
110
+ return yield* h.div({ class: "user" }, [h.h1(`User ${id}`), outlet]);
111
+ });
112
+
113
+ Router.layout({ component: UserShell }, [settingsRoute, postsRoute]);
114
+ ```
115
+
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.
117
+
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`.
119
+
120
+ ### Layout persistence
121
+
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.
123
+
124
+ ## Sealing the tree
125
+
126
+ `Router.router(root, { notFound })` compiles the tree eagerly (stamping leaf references so `href` works) and captures the app-level not-found page:
127
+
128
+ ```typescript
129
+ export const App = Router.router(
130
+ Router.layout({ component: Shell }, [
131
+ homeRoute,
132
+ Router.layout({ component: UserShell }, [settingsRoute, postsRoute]),
133
+ ]),
134
+ { notFound: () => h.section({ id: "page" }, [h.h2("404 — page not found")]) },
135
+ );
136
+ ```
137
+
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.
139
+
140
+ ## Type-safe links with `href`
141
+
142
+ `href(leafRef, args)` builds a URL from a leaf route reference (the value returned by `Router.route`). Path params are **required** in the argument type and query is optional when every query field is optional:
143
+
144
+ ```typescript
145
+ import { href } from "@weftui/router";
146
+
147
+ const Home = Component.make(() =>
148
+ h.nav([
149
+ h.a({ href: href(settingsRoute, { path: { id: 1 } }) }, "User 1 settings"),
150
+ h.a({ href: href(postsRoute, { path: { id: 2 }, query: { sort: "new" } }) }, "User 2 posts"),
151
+ ]),
152
+ );
153
+ ```
154
+
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).
156
+
157
+ ## Not-found
158
+
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:
160
+
161
+ ```typescript
162
+ import { notFound, Router } from "@weftui/router";
163
+
164
+ Router.route("users/:id", {
165
+ path: idParam,
166
+ component: Component.gen(function* () {
167
+ const { id } = yield* Router.params(idParam);
168
+ if (id < 0) return yield* notFound();
169
+ return yield* h.div(`User ${id}`);
170
+ }),
171
+ });
172
+ ```
173
+
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).
175
+
176
+ ## Client setup
177
+
178
+ 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. Provide it through a long-lived `ManagedRuntime` rather than `Effect.provide` at the node level:
179
+
180
+ ```typescript
181
+ // entry-client.ts
182
+ import { hydrate } from "@weftui/dom/client";
183
+ import { RouterApp, RouterLive } from "@weftui/router/client";
184
+ import { ManagedRuntime } from "effect";
185
+ import { App } from "./app";
186
+
187
+ const root = document.getElementById("root")!;
188
+ const runtime = ManagedRuntime.make(RouterLive(App));
189
+ void runtime.runPromise(hydrate(RouterApp(App), root));
190
+ ```
191
+
192
+ For a client-only app (no SSR), swap `hydrate` for `mount` — everything else is identical.
193
+
194
+ ### Link interception
195
+
196
+ 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.
197
+
198
+ ## Programmatic navigation
199
+
200
+ For navigation that isn't a link click, `@weftui/router/client` exposes typed helpers. They run as `Effect`s within the `RouterLive` layer (except `back` / `forward`, which only touch `window.history`):
201
+
202
+ ```typescript
203
+ import {
204
+ back,
205
+ forward,
206
+ navigate,
207
+ patchQuery,
208
+ push,
209
+ replace,
210
+ setQuery,
211
+ } from "@weftui/router/client";
212
+
213
+ // Typed: build the URL from a leaf ref + decoded args (same rules as `href`).
214
+ yield * navigate(postsRoute, { path: { id: 42 }, query: { sort: "new" } });
215
+ yield * navigate(settingsRoute, { path: { id: 42 } }, { replace: true });
216
+
217
+ // Raw path + search string.
218
+ yield * push("/users/1/posts?sort=new");
219
+ yield * replace("/users/1/settings");
220
+
221
+ // History stepping (popstate resyncs the router).
222
+ yield * back();
223
+ yield * forward();
224
+
225
+ // Change only the current route's query, re-encoded through its query schema.
226
+ // The path is kept, so the leaf stays mounted and `queryStream` readers update.
227
+ yield * setQuery({ sort: "old" }); // replaces the query
228
+ yield * patchQuery({ sort: "old" }); // merges into the current query
229
+ ```
230
+
231
+ - **`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`.
232
+ - **`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.
233
+
234
+ ## Server setup
235
+
236
+ 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`).
237
+
238
+ The document shell is itself a `ComponentSlot` that splices the app via `yield* Router.Outlet` — exactly like a layout receives its outlet:
239
+
240
+ ```typescript
241
+ // entry-server.ts
242
+ import { Component, h } from "@weftui/core";
243
+ import { Router } from "@weftui/router";
244
+ import { RouterServer } from "@weftui/router/server";
245
+ import { Effect } from "effect";
246
+ import { App } from "./app";
247
+
248
+ const documentShell = Component.gen(function* () {
249
+ const app = yield* Router.Outlet;
250
+ return yield* h.html({ lang: "en" }, [
251
+ h.head([h.meta({ charset: "utf-8" }), h.title("My app")]),
252
+ h.body([
253
+ h.div({ id: "root" }, [app]),
254
+ h.script({ type: "module", src: "/src/entry-client.ts" }),
255
+ ]),
256
+ ]);
257
+ });
258
+
259
+ // { html, status } — `<!DOCTYPE html>` is prepended for you.
260
+ export const render = (url: string) =>
261
+ Effect.runPromise(RouterServer.render(App, { document: documentShell, url }));
262
+
263
+ // Or a Web fetch-style handler, ready to bridge into Vite or any Web server.
264
+ export const handler = RouterServer.toWebHandler(App, { document: documentShell });
265
+ ```
266
+
267
+ `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.
268
+
269
+ ### `@effect/platform` is the spine
270
+
271
+ The tree is the authoring surface, but `@effect/platform`'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 `setPath(pathSchema)`, `setUrlParams(querySchema)`, and a `RouterNotFound → 404` error. Both sides read that one definition, so they always agree:
272
+
273
+ - **Server** — `RouterServer` dispatches through `HttpApiBuilder` (platform owns request→leaf matching, path/query decode, and the 404 status).
274
+ - **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.
275
+
276
+ ## Errors
277
+
278
+ | Error | Raised by | Recover with |
279
+ | ------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------- |
280
+ | `RouterNotFound` | `notFound()`, or no route matched | `Boundary.catchTag("RouterNotFound", …)` (or the app-level `notFound` page) |
281
+ | `RouterParamsError` | `Router.params` / `Router.query` on a missing/invalid key or no match | `Boundary.catchTag("RouterParamsError", …)` |
282
+
283
+ Both are modeled as `Schema.TaggedError`, so they encode/decode across the wire the same way `Boundary.rpc` replays typed failures.
284
+
285
+ ## `Boundary.rpc` interplay
286
+
287
+ 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).
288
+
289
+ ## See also
290
+
291
+ - [`@weftui/router` API reference](https://weftui.dev/docs/reference/router)
292
+ - [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/platform` spine
293
+ - [Component Authoring](https://weftui.dev/docs/how-to/author-components) — `Component.make` / `Component.gen`, the idiomatic way to write route components
294
+ - [Server-Side Rendering](https://weftui.dev/docs/how-to/render-on-the-server) — `renderToStringHydratable`, `hydrate`, and `Boundary.rpc`
295
+ - [RPC Data Boundaries](https://weftui.dev/docs/how-to/load-data-with-rpc) — `Boundary.rpc`, the `Resource` handle, and the four lifecycles
296
+ - [`packages/router/router.specs.md`](https://github.com/stefvw93/weft/blob/main/packages/router/router.specs.md) — the full specification