@weftui/core 0.27.1 → 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.
@@ -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,31 +175,41 @@ 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. Provide it through a long-lived `ManagedRuntime` rather than `Effect.provide` at the node level:
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
184
- import { hydrate } from "@weftui/dom/client";
190
+ import { WeftApp } from "@weftui/dom/client";
185
191
  import { RouterApp, RouterLive } from "@weftui/router/client";
186
- import { ManagedRuntime } from "effect";
192
+ import { Effect } from "effect";
187
193
  import { App } from "./app";
188
194
 
189
195
  const root = document.getElementById("root")!;
190
- const runtime = ManagedRuntime.make(RouterLive(App));
191
- void runtime.runPromise(hydrate(RouterApp(App), root));
196
+ const app = WeftApp.make(RouterLive(App));
197
+ void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));
192
198
  ```
193
199
 
194
- For a client-only app (no SSR), swap `hydrate` for `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`:
263
+
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`)
249
267
 
250
- The document shell is itself a `ComponentSlot` that splices the app via `yield* Router.Outlet` exactly like a layout receives its outlet:
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
@@ -32,7 +32,7 @@ Use a plain function when:
32
32
 
33
33
  ## Components with internal state
34
34
 
35
- When a component needs reactive state, use `Effect.gen` to set it up before building the tree. The component function still runs once the setup happens at mount time:
35
+ When a component needs reactive state, use `Effect.gen` to set it up before building the tree. The component function still runs once. The setup happens at mount time:
36
36
 
37
37
  ```typescript
38
38
  import { h } from "@weftui/core";
@@ -49,25 +49,28 @@ const Counter = () =>
49
49
  });
50
50
  ```
51
51
 
52
- The return type here is `Effect.Effect<Node, never, never>` itself a valid `Node`, so it composes naturally with other tree-building calls. As soon as such a component is reused or takes props, prefer wrapping the same generator in [`Component.gen`](#componentgen--componentmake-for-reusable-components) (below) so the caller's reactive prop and children channels flow into its node type.
52
+ The return type here is `Effect.Effect<Node, never, never>`, itself a valid `Node`, so it composes naturally with other tree-building calls.
53
+
54
+ As soon as such a component is reused or takes props, wrap the same generator in [`Component.gen`](#componentgen--componentmake-for-reusable-components) (below). That way the caller's reactive prop and children channels flow into its node type.
53
55
 
54
56
  ## Component scope and background effects
55
57
 
56
- Every component instance is rendered under its own **instance scope** a child of the
58
+ Every component instance is rendered under its own **instance scope**, a child of the
57
59
  mount scope created fresh for that instance. Anything bound to the instance scope lives
58
- exactly as long as the component is mounted and is torn down automatically when the
59
- component unmounts (or when the whole tree unmounts via the `MountHandle`). The renderer
60
- provides this scope as the ambient `Scope.Scope` while it evaluates the component body,
61
- so it is already in context when you need it.
60
+ exactly as long as the component is mounted. It is torn down automatically when the
61
+ component unmounts (or when its root unmounts via `RootHandle.unmount()`).
62
+
63
+ The renderer provides this scope as the ambient `Scope.Scope` while it evaluates the
64
+ component body, so it is already in context when you need it.
62
65
 
63
- This matters the moment a component starts **background work** a subscription, an
66
+ This matters the moment a component starts **background work**: a subscription, an
64
67
  observer of a `ref`, a polling timer, anything you `fork`. The rule:
65
68
 
66
69
  > Fork background work with **`Effect.forkScoped`**, never a bare `Effect.forkChild`.
67
70
 
68
71
  `Effect.forkScoped` attaches the fiber to the instance scope, so it keeps running for
69
72
  the component's lifetime and is interrupted on unmount. A bare `Effect.forkChild` instead
70
- attaches the fiber to the component-body fiber the one that runs your `Effect.gen` to
73
+ attaches the fiber to the component-body fiber, the one that runs your `Effect.gen` to
71
74
  produce the tree. That fiber completes the instant the gen returns its node, so the
72
75
  forked work is cancelled almost immediately.
73
76
 
@@ -86,8 +89,8 @@ const AutoFocusInput = () =>
86
89
  Stream.filter(Option.isSome),
87
90
  Stream.take(1),
88
91
  Stream.runForEach((el) => Effect.sync(() => el.value.focus())),
89
- Effect.forkScoped, // ✅ tied to the instance scope survives until unmount
90
- // Effect.forkChild, // ❌ tied to the body fiber interrupted when the gen returns
92
+ Effect.forkScoped, // ✅ tied to the instance scope: survives until unmount
93
+ // Effect.forkChild, // ❌ tied to the body fiber: interrupted when the gen returns
91
94
  );
92
95
 
93
96
  return yield* h.input({ ref: inputRef, type: "text" });
@@ -96,7 +99,7 @@ const AutoFocusInput = () =>
96
99
 
97
100
  You do not manage the scope yourself: you do not create it, close it, or pass it
98
101
  around. `forkScoped` reads it from context, and unmount closes it for you. If you ever
99
- fork outside a component body (rare), you must supply a `Scope.Scope` yourself — the
102
+ fork outside a component body (rare), you must supply a `Scope.Scope` yourself. The
100
103
  type system will tell you, because `forkScoped` carries a `Scope.Scope` requirement.
101
104
 
102
105
  See `examples/element-ref` for the auto-focus, measure, and canvas recipes built on
@@ -106,8 +109,8 @@ this pattern.
106
109
 
107
110
  When you want the caller's reactive prop types to flow into the returned node's type, use one of the `Component` factories. Both have the same call semantics; pick the body style that fits:
108
111
 
109
- - **`Component.make`** body is a plain function returning any `Effect` (typically a `Node`). Use for one-liners and pipe compositions.
110
- - **`Component.gen`** body is a generator. Use when you need `yield*` to set up local state or pull from services.
112
+ - **`Component.make`**: body is a plain function returning any `Effect` (typically a `Node`). Use for one-liners and pipe compositions.
113
+ - **`Component.gen`**: body is a generator. Use when you need `yield*` to set up local state or pull from services.
111
114
 
112
115
  ```typescript
113
116
  import { Component, h, Source } from "@weftui/core";
@@ -125,14 +128,16 @@ const Card = Component.make((props: CardProps) =>
125
128
  );
126
129
  ```
127
130
 
128
- `Source.Source<string>` is Weft's caller-facing prop vocabulary a single type covering a static `string`, a `Stream<string>`, an `Effect<string>`, or a `Subscribable<string>` so you don't hand-write `string | Stream.Stream<string> | …` on every prop. Passing a `Source` straight to `h` (as above) is all you need when the value is just spliced into the tree; the renderer normalizes it.
131
+ `Source.Source<string>` is Weft's caller-facing prop vocabulary: a single type covering a static `string`, a `Stream<string>`, an `Effect<string>`, or a `Subscribable<string>`. You don't hand-write `string | Stream.Stream<string> | …` on every prop.
132
+
133
+ Passing a `Source` straight to `h` (as above) is all you need when the value is just spliced into the tree. The renderer normalizes it.
129
134
 
130
135
  Now the caller's stream types are visible in the returned node:
131
136
 
132
137
  ```typescript
133
138
  declare const titleStream: Stream.Stream<string, never, I18nService>;
134
139
 
135
- // Node<never, I18nService> I18nService requirement flows out
140
+ // Node<never, I18nService>: I18nService requirement flows out
136
141
  const card = Card({ title: titleStream });
137
142
  ```
138
143
 
@@ -140,7 +145,7 @@ Without a `Component` factory, a plain function's return type is fixed at defini
140
145
 
141
146
  ### Body `E`/`R` inference
142
147
 
143
- You don't declare the body's `E`/`R` channels explicitly — they're inferred from the returned (or yielded) effect:
148
+ You don't declare the body's `E`/`R` channels explicitly. They're inferred from the returned (or yielded) effect:
144
149
 
145
150
  - The body's `E`/`R` come from whatever effects appear inside.
146
151
  - The caller's reactive prop channels and reactive children channels are unioned on top at the call site.
@@ -156,7 +161,7 @@ type Component.Children<Input = never> =
156
161
  | ((input: Input) => readonly Renderable[]);
157
162
  ```
158
163
 
159
- The function form is the render-prop / slot pattern the component invokes the function with whatever input it chooses, and the returned array's `E`/`R` propagate out:
164
+ The function form is the render-prop / slot pattern. The component invokes the function with whatever input it chooses, and the returned array's `E`/`R` propagate out:
160
165
 
161
166
  ```typescript
162
167
  const ItemList = Component.make(
@@ -169,7 +174,7 @@ ItemList({ items: ["a", "b"] }, (item) => [h.li(item)]);
169
174
 
170
175
  ## Props typing
171
176
 
172
- For a prop that accepts both static and reactive values, type it as [`Source.Source<T>`](https://weftui.dev/docs/reference/core#source-namespace) rather than hand-writing the union. `Source.Source<T>` **is** that union `T | Stream<T> | Effect<T> | Subscribable<T>` so the caller can pass a plain value or any reactive shape interchangeably, and you write it once:
177
+ For a prop that accepts both static and reactive values, type it as [`Source.Source<T>`](https://weftui.dev/docs/reference/core#source-namespace) rather than hand-writing the union. `Source.Source<T>` **is** that union: `T | Stream<T> | Effect<T> | Subscribable<T>`. The caller can pass a plain value or any reactive shape interchangeably, and you write it once:
173
178
 
174
179
  ```typescript
175
180
  import { Source } from "@weftui/core";
@@ -181,11 +186,11 @@ interface ButtonProps {
181
186
  }
182
187
  ```
183
188
 
184
- When a caller passes a plain string, the component's node type has `never` for that prop's channels. When they pass a `Stream.Stream<string, never, SomeService>`, `SomeService` appears in the `R` channel the extraction is exactly `Source.Success` / `Source.Error` / `Source.Context`.
189
+ When a caller passes a plain string, the component's node type has `never` for that prop's channels. When they pass a `Stream.Stream<string, never, SomeService>`, `SomeService` appears in the `R` channel. The extraction is exactly `Source.Success` / `Source.Error` / `Source.Context`.
185
190
 
186
191
  ### Reading a `Source` in the body
187
192
 
188
- Splicing a `Source` straight into `h` (`[props.label]`) is enough when you only place it in the tree. When the body needs to **read or derive** from the value combine two props, feed a stream operator, drive logic normalize it first with [`Source.toSubscribable`](https://weftui.dev/docs/reference/core#sourcetosubscribablesource-key), which turns any `Source<A>` into an await-first, hot `Subscribable<A>`:
193
+ Splicing a `Source` straight into `h` (`[props.label]`) is enough when you only place it in the tree. When the body needs to **read or derive** from the value (combine two props, feed a stream operator, drive logic), normalize it first with [`Source.toSubscribable`](https://weftui.dev/docs/reference/core#sourcetosubscribablesource-key). It turns any `Source<A>` into an await-first, hot `Subscribable<A>`:
189
194
 
190
195
  ```typescript
191
196
  import { Component, h, Source } from "@weftui/core";
@@ -193,12 +198,19 @@ import { Stream } from "effect";
193
198
 
194
199
  const LoudLabel = Component.gen(function* (props: { label: Source.Source<string> }) {
195
200
  const label = yield* Source.toSubscribable(props.label); // Subscribable<string>
196
- // Now derive from it like any Subscribable static, Effect, and Stream inputs all work.
201
+ // Now derive from it like any Subscribable: static, Effect, and Stream inputs all work.
197
202
  return yield* h.strong([Stream.map(label.changes, (text) => text.toUpperCase())]);
198
203
  });
199
204
  ```
200
205
 
201
- `toSubscribable` is scoped: a `Stream` prop is pumped by a fiber that terminates with the component's instance scope, an `Effect` prop is memoized, an existing `Subscribable` is threaded through by reference, and a static value emits once. It is the same normalization the renderer applies to props internally — reach for it whenever you need the value as a `Subscribable` instead of leaving it opaque.
206
+ `toSubscribable` is scoped:
207
+
208
+ - A `Stream` prop is pumped by a fiber that terminates with the component's instance scope.
209
+ - An `Effect` prop is memoized.
210
+ - An existing `Subscribable` is threaded through by reference.
211
+ - A static value emits once.
212
+
213
+ It is the same normalization the renderer applies to props internally. Reach for it whenever you need the value as a `Subscribable` instead of leaving it opaque.
202
214
 
203
215
  ## Composing components
204
216
 
@@ -231,16 +243,18 @@ const UserAvatar = Component.gen(function* (props: { userId: string }) {
231
243
  return yield* h.img({ src: user.avatarUrl, alt: user.name });
232
244
  });
233
245
 
234
- // Node<never, UserService> regardless of what the caller passes
246
+ // Node<never, UserService>: regardless of what the caller passes
235
247
  const avatar = UserAvatar({ userId: "123" });
236
248
  ```
237
249
 
238
- Provide the service at the mount boundary:
250
+ Give the service to the app layer:
239
251
 
240
252
  ```typescript
241
- void Effect.runPromise(
242
- mount(App(), document.getElementById("root")!).pipe(Effect.provide(UserServiceLive)),
243
- );
253
+ import { WeftApp } from "@weftui/dom/client";
254
+ import { Effect } from "effect";
255
+
256
+ const app = WeftApp.make(UserServiceLive);
257
+ void Effect.runPromise(WeftApp.mount(app, App(), document.getElementById("root")!));
244
258
  ```
245
259
 
246
260
  ## Returning fragments
@@ -258,7 +272,7 @@ const TableCells = ({ row }: { row: Row }) =>
258
272
 
259
273
  ## See also
260
274
 
261
- - [The Combinator API](https://weftui.dev/docs/explanation/combinator-api) `h`, `h.fragment`, and how `E`/`R` accumulate
262
- - [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives) the `Source` vocabulary props accept
263
- - [Add Routing](https://weftui.dev/docs/how-to/add-routing) route components are `Component` slots
264
- - [`@weftui/core` reference](https://weftui.dev/docs/reference/core) `Component`, `Source`, and the full surface
275
+ - [The Combinator API](https://weftui.dev/docs/explanation/combinator-api): `h`, `h.fragment`, and how `E`/`R` accumulate
276
+ - [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives): the `Source` vocabulary props accept
277
+ - [Add Routing](https://weftui.dev/docs/how-to/add-routing): route components are `Component` slots
278
+ - [`@weftui/core` reference](https://weftui.dev/docs/reference/core): `Component`, `Source`, and the full surface
@@ -0,0 +1,144 @@
1
+ ---
2
+ title: Compose Behavior and Markup
3
+ order: 13
4
+ section: how-to
5
+ description: "Use Props.merge to combine a behavior prop bag with your own markup: chained handlers, ref fan-out, and reactive classes on one element."
6
+ ---
7
+
8
+ # Compose Behavior and Markup
9
+
10
+ **Goal:** share behavior (aria wiring, handlers, refs, reactive state) without
11
+ giving up ownership of the element it applies to.
12
+
13
+ ## The problem
14
+
15
+ Behavior and markup usually want different owners. A dropdown's keyboard
16
+ handling, `aria-expanded` wiring and anchor ref are worth writing once. The
17
+ button itself is yours: your classes, your text, your extra handlers.
18
+
19
+ Object spread cannot combine them. `{ ...behavior, ...mine }` silently drops the
20
+ behavior's `onclick` when you supply your own, and drops its `ref` when you
21
+ supply yours. Nothing warns you.
22
+
23
+ `Props.merge` reconciles both bags instead.
24
+
25
+ ## Behavior as a prop bag
26
+
27
+ A behavior primitive is a plain Effect that yields a prop bag. There is no
28
+ component wrapper and no hook rules, so you can `yield*` it anywhere and hold
29
+ the result.
30
+
31
+ ```ts
32
+ import { Effect, Option, SubscriptionRef } from "effect";
33
+
34
+ const makeDisclosure = () =>
35
+ Effect.gen(function* () {
36
+ const isOpen = yield* SubscriptionRef.make(false);
37
+ const anchor = yield* SubscriptionRef.make(Option.none<HTMLElement>());
38
+
39
+ const trigger = {
40
+ ref: anchor,
41
+ "aria-expanded": SubscriptionRef.changes(isOpen),
42
+ onclick: () => SubscriptionRef.update(isOpen, (open) => !open),
43
+ };
44
+
45
+ return { isOpen, trigger };
46
+ });
47
+ ```
48
+
49
+ `makeDisclosure` returns a plain object, not a `DomProps`-typed value. `merge`
50
+ accepts it as-is: it dispatches on each key's name, not on the bag's declared
51
+ type.
52
+
53
+ ## Merge it onto your element
54
+
55
+ You write the element. The bag merges onto it.
56
+
57
+ ```ts
58
+ import { h } from "@weftui/core";
59
+ import { Props } from "@weftui/dom";
60
+
61
+ const Panel = () =>
62
+ Effect.gen(function* () {
63
+ const disclosure = yield* makeDisclosure();
64
+ const measure = yield* SubscriptionRef.make(Option.none<HTMLElement>());
65
+
66
+ return yield* h.button(
67
+ Props.merge(disclosure.trigger, {
68
+ class: Props.cx("btn", { "btn--open": SubscriptionRef.changes(disclosure.isOpen) }),
69
+ onclick: (ev: MouseEvent) => trackClick(ev),
70
+ ref: measure,
71
+ }),
72
+ "Details",
73
+ );
74
+ });
75
+ ```
76
+
77
+ Three rules earn their keep in that one call:
78
+
79
+ 1. **Handlers chain.** The disclosure toggles, then `trackClick` runs. Both
80
+ always run, and a failure in one does not prevent the other. A broken
81
+ analytics call cannot block the toggle.
82
+ 2. **Refs fan out.** `anchor` and `measure` both receive the element. Spread
83
+ would have kept only one, silently.
84
+ 3. **`cx` takes a reactive condition.** `btn--open` follows `isOpen`, and only
85
+ the class attribute updates.
86
+
87
+ Type an inline handler's event explicitly, as `(ev: MouseEvent)` above. `merge`
88
+ does not know which element the bag will land on, so it cannot infer it.
89
+
90
+ ## Typed errors flow through
91
+
92
+ A handler that fails with a tagged error, or needs a service, keeps both
93
+ channels through the merge. They surface on the component's `Node<E, R>`, so the
94
+ app must provide the service and can catch the error at a boundary.
95
+
96
+ ```ts
97
+ declare const rowBehavior: object;
98
+ declare const itemId: string;
99
+
100
+ const deleteItem = Effect.gen(function* () {
101
+ const files = yield* FileService;
102
+ yield* files.remove(itemId);
103
+ });
104
+
105
+ // The merged node requires FileService and can fail with whatever error
106
+ // `files.remove` declares. Both channels flow through `merge` untouched.
107
+ h.button(Props.merge(rowBehavior, { onclick: () => deleteItem }), "Delete");
108
+ ```
109
+
110
+ ## Two gotchas that differ from spread
111
+
112
+ - **`false` on a handler is an explicit opt-out and wins.** `null` and
113
+ `undefined` mean "not provided", so the other side survives instead.
114
+ - **Every other key is genuinely last-wins.** Forwarding an omitted optional
115
+ prop (`{ id: props.id }`) still overwrites a default with `undefined`, the
116
+ same as `{ ...base, ...override }` would. Guard at the call site if that
117
+ matters.
118
+
119
+ The [reference](https://weftui.dev/docs/reference/dom#propsmerge) has the full per-key rule
120
+ table, including the `style` and reactive-class cases this guide doesn't
121
+ cover.
122
+
123
+ ## When to use
124
+
125
+ Reach for `Props.merge` when two parties contribute props to one element: a
126
+ shared behavior and a caller, or a base variant and a caller's override. For
127
+ a single bag you already control, write the object directly. Merge earns its
128
+ cost only when a key could collide.
129
+
130
+ `Props.merge` is pure: calling it has no side effects and subscribes
131
+ nothing. A merged `class` that turns out reactive is a `Stream` description.
132
+ The renderer subscribes it once the element mounts, the same as any other
133
+ reactive prop.
134
+
135
+ ## See also
136
+
137
+ - [Headless Menu example](https://github.com/stefvw93/weft/tree/main/examples/headless-menu): a full behavior
138
+ primitive (`Menu.trigger`/`popup`/`item`) merged onto consumer-owned markup,
139
+ with handler chaining, ref fan-out, and a service requirement flowing
140
+ through the merge into `Node<E, R>`.
141
+ - [`@weftui/dom` reference](https://weftui.dev/docs/reference/dom): the full per-key rules and `cx` grammar
142
+ - [Use Element Refs](https://weftui.dev/docs/how-to/use-element-refs): the single-ref contract that fan-out builds on
143
+ - [Style Reactively](https://weftui.dev/docs/how-to/style-reactively): per-property style streams and `cx`
144
+ - [The Combinator API](https://weftui.dev/docs/explanation/combinator-api): why elements are plain data you always own