@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,264 @@
1
+ ---
2
+ title: Component Authoring
3
+ order: 1
4
+ section: how-to
5
+ description: Plain functions vs. Component.gen / Component.make, instance scope, fragments, render-prop children, and service requirements.
6
+ ---
7
+
8
+ # Component Authoring
9
+
10
+ Weft components are plain TypeScript functions that return a `Node<E, R>`. This guide covers the two ways to define them and when to choose each.
11
+
12
+ ## Plain functions
13
+
14
+ The simplest component is just a function:
15
+
16
+ ```typescript
17
+ import { h } from "@weftui/core";
18
+
19
+ function Greeting({ name }: { name: string }) {
20
+ return h.p(`Hello, ${name}!`);
21
+ }
22
+
23
+ // Call it like a function
24
+ Greeting({ name: "World" });
25
+ ```
26
+
27
+ Use a plain function when:
28
+
29
+ - Props are all static (strings, numbers, plain functions)
30
+ - The component has no internal state
31
+ - You don't need the caller's reactive prop types to propagate
32
+
33
+ ## Components with internal state
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:
36
+
37
+ ```typescript
38
+ import { h } from "@weftui/core";
39
+ import { Effect, SubscriptionRef } from "effect";
40
+
41
+ const Counter = () =>
42
+ Effect.gen(function* () {
43
+ const count = yield* SubscriptionRef.make(0);
44
+
45
+ return yield* h.div([
46
+ h.span([count.changes]),
47
+ h.button({ onclick: () => SubscriptionRef.update(count, (n) => n + 1) }, "+"),
48
+ ]);
49
+ });
50
+ ```
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.
53
+
54
+ ## Component scope and background effects
55
+
56
+ Every component instance is rendered under its own **instance scope** — a child of the
57
+ 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.
62
+
63
+ This matters the moment a component starts **background work** — a subscription, an
64
+ observer of a `ref`, a polling timer, anything you `fork`. The rule:
65
+
66
+ > Fork background work with **`Effect.forkScoped`**, never a bare `Effect.fork`.
67
+
68
+ `Effect.forkScoped` attaches the fiber to the instance scope, so it keeps running for
69
+ the component's lifetime and is interrupted on unmount. A bare `Effect.fork` instead
70
+ attaches the fiber to the component-body fiber — the one that runs your `Effect.gen` to
71
+ produce the tree. That fiber completes the instant the gen returns its node, so the
72
+ forked work is cancelled almost immediately.
73
+
74
+ Concretely, an observer that runs an effect when a `ref`'s element mounts:
75
+
76
+ ```typescript
77
+ import { h } from "@weftui/core";
78
+ import { Effect, Option, pipe, Stream, SubscriptionRef } from "effect";
79
+
80
+ const AutoFocusInput = () =>
81
+ Effect.gen(function* () {
82
+ const inputRef = yield* SubscriptionRef.make<Option.Option<HTMLInputElement>>(Option.none());
83
+
84
+ yield* pipe(
85
+ inputRef.changes,
86
+ Stream.filter(Option.isSome),
87
+ Stream.take(1),
88
+ Stream.runForEach((el) => Effect.sync(() => el.value.focus())),
89
+ Effect.forkScoped, // ✅ tied to the instance scope — survives until unmount
90
+ // Effect.fork, // ❌ tied to the body fiber — interrupted when the gen returns
91
+ );
92
+
93
+ return yield* h.input({ ref: inputRef, type: "text" });
94
+ });
95
+ ```
96
+
97
+ You do not manage the scope yourself: you do not create it, close it, or pass it
98
+ 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
100
+ type system will tell you, because `forkScoped` carries a `Scope.Scope` requirement.
101
+
102
+ See `examples/element-ref` for the auto-focus, measure, and canvas recipes built on
103
+ this pattern.
104
+
105
+ ## `Component.gen` / `Component.make` for reusable components
106
+
107
+ 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
+
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.
111
+
112
+ ```typescript
113
+ import { Component, h, Source } from "@weftui/core";
114
+
115
+ interface CardProps {
116
+ title: Source.Source<string>;
117
+ body?: Source.Source<string>;
118
+ }
119
+
120
+ const Card = Component.make((props: CardProps) =>
121
+ h.div({ class: "card" }, [
122
+ h.h3({ class: "card-title" }, [props.title]),
123
+ props.body ? h.p({ class: "card-body" }, [props.body]) : null,
124
+ ]),
125
+ );
126
+ ```
127
+
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.
129
+
130
+ Now the caller's stream types are visible in the returned node:
131
+
132
+ ```typescript
133
+ declare const titleStream: Stream.Stream<string, never, I18nService>;
134
+
135
+ // Node<never, I18nService> — I18nService requirement flows out
136
+ const card = Card({ title: titleStream });
137
+ ```
138
+
139
+ Without a `Component` factory, a plain function's return type is fixed at definition time and won't reflect the caller's reactive prop types.
140
+
141
+ ### Body `E`/`R` inference
142
+
143
+ You don't declare the body's `E`/`R` channels explicitly — they're inferred from the returned (or yielded) effect:
144
+
145
+ - The body's `E`/`R` come from whatever effects appear inside.
146
+ - The caller's reactive prop channels and reactive children channels are unioned on top at the call site.
147
+ - Static prop values (`string`, `number`, plain functions) contribute `never`.
148
+
149
+ ### Children: array or function
150
+
151
+ Both factories accept an optional second `children` argument, typed as:
152
+
153
+ ```typescript
154
+ type Component.Children<Input = never> =
155
+ | readonly Renderable[]
156
+ | ((input: Input) => readonly Renderable[]);
157
+ ```
158
+
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:
160
+
161
+ ```typescript
162
+ const ItemList = Component.make(
163
+ (props: { items: readonly string[] }, renderItem: (item: string) => readonly Renderable[]) =>
164
+ h.ul(props.items.flatMap(renderItem)),
165
+ );
166
+
167
+ ItemList({ items: ["a", "b"] }, (item) => [h.li(item)]);
168
+ ```
169
+
170
+ ## Props typing
171
+
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:
173
+
174
+ ```typescript
175
+ import { Source } from "@weftui/core";
176
+
177
+ interface ButtonProps {
178
+ label: Source.Source<string>; // static or reactive text
179
+ disabled?: Source.Source<boolean>; // static or reactive boolean
180
+ onclick?: () => void | Effect.Effect<void>; // plain or Effect-returning handler
181
+ }
182
+ ```
183
+
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`.
185
+
186
+ ### Reading a `Source` in the body
187
+
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>`:
189
+
190
+ ```typescript
191
+ import { Component, h, Source } from "@weftui/core";
192
+ import { Stream } from "effect";
193
+
194
+ const LoudLabel = Component.gen(function* (props: { label: Source.Source<string> }) {
195
+ const label = yield* Source.toSubscribable(props.label); // Subscribable<string>
196
+ // Now derive from it like any Subscribable — static, Effect, and Stream inputs all work.
197
+ return yield* h.strong([Stream.map(label.changes, (text) => text.toUpperCase())]);
198
+ });
199
+ ```
200
+
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.
202
+
203
+ ## Composing components
204
+
205
+ Call component functions directly inside a children array:
206
+
207
+ ```typescript
208
+ import { h } from "@weftui/core";
209
+
210
+ function App() {
211
+ return h.div({ class: "app" }, [
212
+ Header({ title: "My App" }),
213
+ h.main([Sidebar(), h.article([Content({ id: 1 })])]),
214
+ Footer(),
215
+ ]);
216
+ }
217
+ ```
218
+
219
+ Children arrays accumulate `E`/`R` from all their members. The parent node's type reflects the union of all children's channels.
220
+
221
+ ## Components that require services
222
+
223
+ If a component's render function uses a service via `yield*`, that service appears in the component's `CompR` parameter:
224
+
225
+ ```typescript
226
+ import { Component, h } from "@weftui/core";
227
+
228
+ const UserAvatar = Component.gen(function* (props: { userId: string }) {
229
+ const userService = yield* UserService;
230
+ const user = yield* userService.getUser(props.userId);
231
+ return yield* h.img({ src: user.avatarUrl, alt: user.name });
232
+ });
233
+
234
+ // Node<never, UserService> — regardless of what the caller passes
235
+ const avatar = UserAvatar({ userId: "123" });
236
+ ```
237
+
238
+ Provide the service at the mount boundary:
239
+
240
+ ```typescript
241
+ void Effect.runPromise(
242
+ mount(App(), document.getElementById("root")!).pipe(Effect.provide(UserServiceLive)),
243
+ );
244
+ ```
245
+
246
+ ## Returning fragments
247
+
248
+ When a component needs to return multiple sibling elements without a wrapper, use `h.fragment`:
249
+
250
+ ```typescript
251
+ import { h } from "@weftui/core";
252
+
253
+ const TableCells = ({ row }: { row: Row }) =>
254
+ h.fragment([h.td(row.name), h.td(row.value), h.td(row.status)]);
255
+ ```
256
+
257
+ `h.fragment` returns a `Node<E, R>` that accumulates channels from all its children.
258
+
259
+ ## See also
260
+
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
@@ -0,0 +1,76 @@
1
+ ---
2
+ title: Handle Forms
3
+ order: 8
4
+ section: how-to
5
+ description: Build a controlled form with SubscriptionRef field state, reactive Schema validation, and an Effect-returning submit handler.
6
+ ---
7
+
8
+ # Handle Forms
9
+
10
+ **Goal:** a controlled form whose inputs drive `SubscriptionRef` state, whose errors update reactively as the user types, and whose submit runs an Effect.
11
+
12
+ Each field is a `SubscriptionRef`. Bind it with `oninput`, derive validation from its `.changes` stream, and return an `Effect` from `onsubmit` (after `preventDefault`).
13
+
14
+ ```typescript
15
+ import { h } from "@weftui/core";
16
+ import { Effect, Either, Schema, Stream, SubscriptionRef } from "effect";
17
+
18
+ const Email = Schema.String.pipe(
19
+ Schema.filter((s) => s.includes("@"), { message: () => "Must contain @" }),
20
+ Schema.filter((s) => s.includes("."), { message: () => "Must contain a domain" }),
21
+ );
22
+
23
+ const LoginForm = () =>
24
+ Effect.gen(function* () {
25
+ const email = yield* SubscriptionRef.make("");
26
+ const status = yield* SubscriptionRef.make<string | null>(null);
27
+
28
+ // Validation is a stream derived from the field — it re-runs as the user types.
29
+ const error = Stream.map(email.changes, (value) => {
30
+ if (value.length === 0) return null; // don't nag an empty field
31
+ return Either.match(Schema.decodeUnknownEither(Email)(value), {
32
+ onLeft: (e) => e.message.split(":").pop()?.trim() ?? "Invalid",
33
+ onRight: () => null,
34
+ });
35
+ });
36
+
37
+ return yield* h.form(
38
+ {
39
+ onsubmit: (e) => {
40
+ e.preventDefault();
41
+ return Effect.gen(function* () {
42
+ yield* SubscriptionRef.set(status, "Submitting…");
43
+ yield* Effect.sleep("1500 millis");
44
+ yield* SubscriptionRef.set(status, "Login successful!");
45
+ });
46
+ },
47
+ },
48
+ [
49
+ h.input({
50
+ type: "email",
51
+ oninput: (e) => SubscriptionRef.set(email, (e.target as HTMLInputElement).value),
52
+ }),
53
+ Stream.map(error, (err) => (err ? h.span({ class: "error-text" }, err) : null)),
54
+ h.button({ type: "submit" }, "Login"),
55
+ h.div([Stream.map(status.changes, (s) => (s ? h.span(s) : null))]),
56
+ ],
57
+ );
58
+ });
59
+ ```
60
+
61
+ ## How it works
62
+
63
+ - **Field state** is a `SubscriptionRef.make("")`; `oninput` writes the current value with `SubscriptionRef.set`. Because the input is driven by the ref, it is a controlled input.
64
+ - **Validation is reactive**, not on-blur or on-submit only: `Stream.map(email.changes, …)` produces an error string (or `null`) on every keystroke. Use [`Schema`](https://effect.website/docs/schema/introduction) to decode — `Schema.decodeUnknownEither(schema)(value)` returns an `Either`, and `Either.match` turns it into UI. A node or `null` in a child slot renders the error or nothing.
65
+ - **Submit returns an Effect.** `onsubmit` calls `e.preventDefault()` and then **returns** an `Effect` (it is not `yield*`-ed inline) — the renderer runs it in a detached fiber, so it can `SubscriptionRef.set`, `Effect.sleep`, read fields with `SubscriptionRef.get`, or call a service.
66
+
67
+ ## Variations
68
+
69
+ - **Cross-field rules** (e.g. "passwords match") combine two fields with `Stream.zipLatestWith` before mapping to an error.
70
+ - **Read a field imperatively** inside the submit handler with `yield* SubscriptionRef.get(field)` rather than threading it through.
71
+
72
+ ## See also
73
+
74
+ - [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives) — `SubscriptionRef.changes` and stream-shaped children
75
+ - [Author Components](https://weftui.dev/docs/how-to/author-components) — Effect-returning and service-aware handlers
76
+ - [examples/form-handling](https://github.com/stefvw93/weft/tree/main/examples/form-handling) — a runnable multi-field form with Schema validation and an async submit
@@ -0,0 +1,70 @@
1
+ ---
2
+ title: Load Async Data
3
+ order: 9
4
+ section: how-to
5
+ description: Show a loading state then resolved content with Stream.concat, and turn a failed fetch into a fallback node with Effect.catchAll — all client-side.
6
+ ---
7
+
8
+ # Load Async Data
9
+
10
+ **Goal:** render a loading placeholder, then the fetched content, and a fallback if the fetch fails — for data that loads **on the client**.
11
+
12
+ Return a `Stream<Node>` that emits the loading node first and the resolved node second, sequenced with `Stream.concat`. Handle failure inside the effect with `Effect.catchAll`, which maps the error to a fallback node.
13
+
14
+ ```typescript
15
+ import { h } from "@weftui/core";
16
+ import { Effect, Stream } from "effect";
17
+
18
+ interface User {
19
+ id: number;
20
+ name: string;
21
+ email: string;
22
+ }
23
+
24
+ const fetchUser = (id: number): Effect.Effect<User, Error> =>
25
+ Effect.gen(function* () {
26
+ yield* Effect.sleep("1000 millis");
27
+ if (id === 3) return yield* Effect.fail(new Error("User not found"));
28
+ return { id, name: `User ${id}`, email: `user${id}@example.com` };
29
+ });
30
+
31
+ const UserCard = ({ id }: { id: number }) =>
32
+ Stream.concat(
33
+ Stream.make(h.div({ class: "loading" }, `Loading user ${id}…`)),
34
+ Stream.fromEffect(
35
+ fetchUser(id).pipe(
36
+ Effect.flatMap((user) => h.div({ class: "user-card" }, [h.h3(user.name), h.p(user.email)])),
37
+ Effect.catchAll((error) => h.div({ class: "error" }, `Error: ${error.message}`)),
38
+ ),
39
+ ),
40
+ );
41
+ ```
42
+
43
+ ## How it works
44
+
45
+ - **`Stream.concat`** sequences two streams: `Stream.make(loadingNode)` emits once immediately, then `Stream.fromEffect(effect)` emits the resolved node when the effect completes. The renderer swaps the DOM in place on the second emission.
46
+ - **`Effect.flatMap((data) => h.div(...))`** builds the content node from the data — `h.*` returns a `Node`, which is an `Effect`, so it composes directly in the pipeline.
47
+ - **`Effect.catchAll((error) => node)`** converts the error channel into a fallback node, so the stream always yields something renderable. The failure never escapes to the mount.
48
+ - **Parallel loading is automatic:** place several async components as siblings and their fetches run concurrently — no orchestration needed.
49
+
50
+ ## When to reach for a boundary instead
51
+
52
+ This is the raw, per-region pattern. When you need **one fallback for several async siblings** (all-or-nothing), use [`Boundary.suspend`](https://weftui.dev/docs/explanation/boundaries-and-suspense). When the data must be resolved on the **server** and replayed on hydrate without a second request, use [`Boundary.rpc`](https://weftui.dev/docs/how-to/load-data-with-rpc) instead — this recipe is purely client-side.
53
+
54
+ ## Blocking on navigation vs streaming in place
55
+
56
+ The `Stream.concat` placeholder above lives on a **child** node, so it always streams in after mount — it never delays a navigation commit. If the component above is a route's leaf, moving the `fetchUser` call into the **body** instead changes that:
57
+
58
+ - **Await in the leaf's own body** — commit-blocking. Navigating to the route pre-runs its component effect to completion before the URL commits: the previous page stays mounted for the fetch, and [`Router.navigating`](https://weftui.dev/docs/reference/router#routernavigating) reports the window.
59
+ - **The `Stream.concat` placeholder pattern above, kept as a child** — streaming. The leaf commits immediately and the region fills in place once the effect resolves.
60
+
61
+ Choose blocking for **primary route content the page is meaningless without** (an article body, a user's profile) — the old page stays visible with no blank or skeleton. Choose streaming for **secondary or slow regions** where partial content is still useful (a comments panel, a "related" rail) — the commit isn't held hostage by one slow fetch.
62
+
63
+ See [Show Navigation Progress](https://weftui.dev/docs/how-to/show-navigation-progress) for rendering pending UI during the blocking window, and the router reference's [Blocking vs streaming data](https://weftui.dev/docs/reference/router#blocking-vs-streaming-data) for the full model.
64
+
65
+ ## See also
66
+
67
+ - [Boundaries and Suspense](https://weftui.dev/docs/explanation/boundaries-and-suspense) — coordinating multiple async regions
68
+ - [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives) — `Stream`/`Effect` as node-producing children
69
+ - [Show Navigation Progress](https://weftui.dev/docs/how-to/show-navigation-progress) — the pending signal for the commit-blocking window
70
+ - [examples/async-data-loading](https://github.com/stefvw93/weft/tree/main/examples/async-data-loading) — loading states, retry, parallel and sequential loads with error boundaries
@@ -0,0 +1,172 @@
1
+ ---
2
+ title: RPC Data Boundaries
3
+ order: 3
4
+ section: how-to
5
+ description: Boundary.rpc — server-resolved, client-refreshable data; the contract/handler split and the Resource handle's four lifecycles.
6
+ ---
7
+
8
+ # RPC Data Boundaries
9
+
10
+ `Boundary.rpc` is Weft's primitive for **server-resolved, client-refreshable** data. One [`Rpc`](https://github.com/Effect-TS/effect/tree/main/packages/rpc) from the app's merged `RpcGroup` backs a single render boundary across four lifecycles: server-side render, hydrate-replay, client refetch, and client-first SPA mount. The rpc's `_tag` is the boundary's stable identity and its payload schema the typed input.
11
+
12
+ ## Overview
13
+
14
+ A `Boundary.rpc` is a **thin consumer**. It carries an rpc, a payload thunk, and a `render` that receives a reactive [`Resource`](https://weftui.dev/docs/reference/core#resourcea); the renderer resolves the rpc through the ambient [`AppRpcClientTag`](https://weftui.dev/docs/reference/core#apprpcclienttag) seam (provided by `@weftui/router`). The same rpc serves every lifecycle, so SSR-replay, refetch, and client-first mount are one mechanism, not three.
15
+
16
+ ```typescript
17
+ import { Boundary, h } from "@weftui/core";
18
+ import { Stream } from "effect";
19
+ import { GetStock } from "./data/inventory";
20
+
21
+ Boundary.rpc(
22
+ GetStock, // the rpc — its _tag + schemas drive the boundary
23
+ () => ({ id: product.id }), // payload thunk — a fresh typed input per call
24
+ (
25
+ resource, // render — receives a reactive Resource, not a bare value
26
+ ) =>
27
+ h.p([
28
+ "in stock: ",
29
+ h.span([Stream.map(resource.value.changes, (s) => String(s.units))]),
30
+ h.button({ type: "button", onclick: () => resource.refetch }, "Refresh"),
31
+ ]),
32
+ { fallback: h.p("loading stock…") }, // shown only on a client-first mount
33
+ );
34
+ ```
35
+
36
+ ## The contract / handler split
37
+
38
+ The rpc **contract** (pure Schema) is shared with the client. The rpc **handler** — the only code that touches server-only services — lives in a Layer the client never imports; tree-shaking keeps it and its transitive imports out of the browser bundle. The split is enforced structurally by which files each entry imports, not by a bundler plugin.
39
+
40
+ ```typescript
41
+ // data/inventory.ts
42
+ import { Rpc, RpcGroup } from "@effect/rpc";
43
+ import { Context, Effect, Layer, Schema } from "effect";
44
+
45
+ // --- Contract (shareable with the client) ---
46
+ export const Stock = Schema.Struct({ units: Schema.Number });
47
+ export const StockKey = Schema.Struct({ id: Schema.Number });
48
+
49
+ // `_tag` ("GetStock") = the stable boundary id; payload schema = the typed input.
50
+ export const GetStock = Rpc.make("GetStock", { payload: StockKey, success: Stock });
51
+
52
+ // The app's merged RpcGroup — shared by both the client and server router wiring.
53
+ export const StockRpcs = RpcGroup.make(GetStock);
54
+
55
+ // --- Handler (server-only; the client never imports this) ---
56
+ class Inventory extends Context.Tag("Inventory")<
57
+ Inventory,
58
+ { readonly stockFor: (id: number) => Effect.Effect<typeof Stock.Type> }
59
+ >() {}
60
+
61
+ const InventoryLive = Layer.succeed(Inventory, {
62
+ stockFor: (id) => Effect.succeed({ units: 7 + (id % 5) }),
63
+ });
64
+
65
+ // `toLayer` binds each rpc to its handler; `Layer.provide` discharges its deps so R = never.
66
+ export const StockLive = StockRpcs.toLayer({
67
+ GetStock: (payload) => Effect.flatMap(Inventory, (inv) => inv.stockFor(payload.id)),
68
+ }).pipe(Layer.provide(InventoryLive));
69
+ ```
70
+
71
+ Declare server-only services with [`ServerTag`](https://weftui.dev/docs/reference/core#servertag) (not `Context.Tag`) when they might be referenced from universal code: the brand makes a leak into `render` a compile error at the `hydrate` call site rather than a runtime surprise.
72
+
73
+ ## Wiring the router
74
+
75
+ `Boundary.rpc` resolves through the ambient `AppRpcClientTag` seam, which `@weftui/router` provides on both sides. Pass the **merged group** to both, plus the **handler Layer** on the server.
76
+
77
+ ```typescript
78
+ // entry-server.ts — in-process client over the handlers + POST /_eui/rpc endpoint
79
+ import { RouterServer } from "@weftui/router/server";
80
+ import { StockLive, StockRpcs } from "./data/inventory";
81
+
82
+ const rpc = { group: StockRpcs, handlers: StockLive } as const;
83
+
84
+ export const handler = RouterServer.toWebHandler(App, { document: documentShell, rpc });
85
+ export const render = (url: string) =>
86
+ Effect.runPromise(RouterServer.render(App, { document: documentShell, rpc, url }));
87
+ ```
88
+
89
+ ```typescript
90
+ // entry-client.ts — network client posting to /_eui/rpc
91
+ import { RouterApp, RouterLive } from "@weftui/router/client";
92
+ import { StockRpcs } from "./data/inventory";
93
+
94
+ const runtime = ManagedRuntime.make(RouterLive(App, { rpc: { group: StockRpcs } }));
95
+ void runtime.runPromise(hydrate(RouterApp(App), root));
96
+ ```
97
+
98
+ - **Server** ([`RouterServer`](https://weftui.dev/docs/reference/router#routerserver)) mounts the handler Layer at `POST /_eui/rpc` (so a client refetch re-runs it on the server) **and** exposes an in-process client over the same handlers for SSR resolution — never a network hop.
99
+ - **Client** ([`RouterLive`](https://weftui.dev/docs/reference/router#routerlive)) provides a network flat rpc client over the merged group, posting to `<origin>/_eui/rpc`.
100
+
101
+ In a **router-less mount** there is no `AppRpcClientTag`, so a `Boundary.rpc` resolves to a typed, descriptive "needs router/rpc" error (not a defect).
102
+
103
+ ## The four lifecycles
104
+
105
+ | Lifecycle | Trigger | What happens |
106
+ | ---------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
107
+ | **SSR** | server render | Resolve the rpc in-process, `successSchema`-encode the result inline as `<script type="application/json">`, render `render(seededResource)` to HTML. |
108
+ | **Hydrate** | `hydrate` on the client | Read the inline payload at the cursor, `successSchema`-decode it, seed the `Resource`, adopt the DOM. **Never re-calls the rpc** (replay). |
109
+ | **Refetch** | `resource.refetch` | Call the rpc again over `POST /_eui/rpc` (re-runs the handler on the server), patch the subtree in place (stale-on-error). |
110
+ | **Client-first mount** | SPA nav into a boundary with no payload | Render `options.fallback`, fork the rpc call, swap in `render(resource)` once it resolves. |
111
+
112
+ Because the SSR path seeds `value` await-first (it emits the seed immediately), the SSR HTML and the adopted DOM are byte-identical — there is **no fallback flash** on the SSR/hydrate path. `fallback` shows only on a client-first mount.
113
+
114
+ ## The `Resource` handle
115
+
116
+ `render` receives a [`Resource<A>`](https://weftui.dev/docs/reference/core#resourcea) (`A` = the rpc's decoded success), not a bare value. After hydrate the region is live:
117
+
118
+ | Field | What it gives you |
119
+ | --------- | ---------------------------------------------------------------------------------------------------- |
120
+ | `value` | A `Subscribable` of the current data — seeded with the SSR payload, updated on a successful refetch. |
121
+ | `refetch` | An `Effect<void>` that re-resolves the rpc with a fresh `payload()` and pushes the new `value`. |
122
+ | `pending` | A `Subscribable<boolean>` — `true` while a refetch is in flight. |
123
+ | `error` | A `Subscribable<Option<unknown>>` — `Some` with the last refetch error (stale-on-error). |
124
+
125
+ ```typescript
126
+ (resource) =>
127
+ h.section({ class: "product" }, [
128
+ h.span([Stream.map(resource.value.changes, (s) => String(s.units))]),
129
+ h.span([Stream.map(resource.pending.changes, (p) => (p ? "refreshing…" : ""))]),
130
+ h.button({ type: "button", onclick: () => resource.refetch }, "Refresh stock"),
131
+ ]);
132
+ ```
133
+
134
+ Wire `refetch` to an event with `onclick: () => resource.refetch` — the handler returns the Effect, which the renderer runs in a detached fiber. A failed refetch leaves the previous `value` intact (stale-on-error); it does **not** unmount the subtree or raise into a failure `Boundary`.
135
+
136
+ ### Channel algebra
137
+
138
+ ```typescript
139
+ Boundary.rpc<R extends Rpc.Any, C extends Node<any, any>>(
140
+ rpc: R,
141
+ payload: () => Rpc.Payload<R>,
142
+ render: (resource: Resource<Rpc.Success<R>>) => C,
143
+ options?: { fallback?: Renderable },
144
+ ): Node<Node.Error<C> | Rpc.Error<R>, Node.Context<C>>;
145
+ ```
146
+
147
+ - **Error** = `render`'s error union plus the rpc's typed `Rpc.Error<R>` (`never` for an rpc with no `error` schema).
148
+ - **Requirement** = exactly `render`'s `R`, **untouched**. There is no `provide`/`RServer` to discharge (the handler lives in the rpc Layer) and no `Exclude` is applied — a server-only tag leaked into `render` stays in `R`, where `hydrate`'s `AssertNoServerOnly` rejects it.
149
+
150
+ ## Typed-failure replay
151
+
152
+ If the rpc declares an `error` schema, a resolved rpc **error** on the SSR pass is `errorSchema`-encoded into an inline failure payload, and the nearest enclosing **failure `Boundary`** renders its fallback. On the client, `hydrate` decodes that payload and re-raises the same error into the same boundary, reproducing the identical fallback DOM — flash-free and without re-resolving the rpc (replay, never retry).
153
+
154
+ ```typescript
155
+ Boundary.catchTag({ tag: "OutOfStock", fallback: (e) => h.p({ class: "error" }, e.reason) }, [
156
+ Boundary.rpc(GetStock, () => ({ id: product.id }), (resource) => /* … */),
157
+ ]);
158
+ ```
159
+
160
+ A transport **defect** (no `Cause.failureOption`), or an rpc with no `error` schema, is **not** replayed; it propagates — a server-side fallback and a client mismatch.
161
+
162
+ ## When to use
163
+
164
+ - **`Boundary.rpc`** — data resolved on the server (behind a server-only service, credential, or private network) and rendered into the initial HTML, then **refreshable** on the client over the same rpc.
165
+ - **`Boundary.suspend`** — async data that loads purely on the client; see the [Boundary API](https://weftui.dev/docs/reference/core#boundarysuspend).
166
+
167
+ ## See also
168
+
169
+ - [`Boundary.rpc` API reference](https://weftui.dev/docs/reference/core#boundaryrpc) — signature, `Resource`, `RpcOptions`, `AppRpcClientTag`
170
+ - [Server-Side Rendering](https://weftui.dev/docs/how-to/render-on-the-server) — the SSR + hydration model this builds on
171
+ - [Routing](https://weftui.dev/docs/how-to/add-routing) — `@weftui/router`, which provides the `AppRpcClientTag` seam
172
+ - [examples/router-ssr](https://github.com/stefvw93/weft/tree/main/examples/router-ssr) — a runnable shop with an SSR-replayed, refetchable live-stock `Boundary.rpc`