@weftui/core 0.30.0 → 0.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,11 +7,135 @@ description: Build a controlled form with SubscriptionRef field state, reactive
7
7
 
8
8
  # Handle Forms
9
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.
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
11
 
12
- Each field is a `SubscriptionRef`. Bind it with `oninput`, derive validation from its `SubscriptionRef.changes(ref)` stream, and return an `Effect` from `onsubmit` (after `preventDefault`).
12
+ ```typescript
13
+ import { h } from "@weftui/core";
14
+ import { Effect, SubscriptionRef } from "effect";
15
+
16
+ const Field = () =>
17
+ Effect.gen(function* () {
18
+ const value = yield* SubscriptionRef.make("");
19
+
20
+ return yield* h.div([
21
+ h.input({
22
+ type: "text",
23
+ oninput: (e) => SubscriptionRef.set(value, e.currentTarget.value),
24
+ }),
25
+ h.div(["You typed: ", SubscriptionRef.changes(value)]),
26
+ ]);
27
+ });
28
+ ```
29
+
30
+ Each field is a `SubscriptionRef`: `oninput` writes it, `SubscriptionRef.changes` streams it back into the tree. `e.currentTarget` is typed to the element itself (`HTMLInputElement` here), so no cast is needed.
31
+
32
+ ## Reactive validation
33
+
34
+ Derive an error stream from the field's `changes`. Decode with [`Schema`](https://effect.website/docs/schema/introduction) and turn the `Result` into UI with `Result.match`:
35
+
36
+ ```typescript
37
+ import { Result, Schema, Stream, SubscriptionRef } from "effect";
38
+
39
+ const Email = Schema.String.pipe(
40
+ Schema.check(Schema.makeFilter((s) => (s.includes("@") ? undefined : "Must contain @"))),
41
+ Schema.check(Schema.makeFilter((s) => (s.includes(".") ? undefined : "Must contain a domain"))),
42
+ );
43
+
44
+ const error = Stream.map(SubscriptionRef.changes(email), (value) => {
45
+ if (value.length === 0) return null; // don't nag an empty field
46
+ return Result.match(Schema.decodeUnknownResult(Email)(value), {
47
+ onFailure: (e) => e.message.split(":").pop()?.trim() ?? "Invalid",
48
+ onSuccess: () => null,
49
+ });
50
+ });
51
+
52
+ h.span([Stream.map(error, (err) => (err ? h.span({ class: "error-text" }, err) : null))]);
53
+ ```
54
+
55
+ This re-runs on every keystroke, not just on blur or submit. A node or `null` in a child slot renders the error or nothing.
56
+
57
+ ## Submit as an Effect
58
+
59
+ `onsubmit` calls `e.preventDefault()`, then **returns** an `Effect` (it is not `yield*`-ed inline). The renderer runs it in a detached fiber:
13
60
 
14
61
  ```typescript
62
+ import { Effect, SubscriptionRef } from "effect";
63
+
64
+ h.form(
65
+ {
66
+ onsubmit: (e) => {
67
+ e.preventDefault();
68
+ return Effect.gen(function* () {
69
+ yield* SubscriptionRef.set(status, "Submitting…");
70
+ yield* Effect.sleep("1500 millis");
71
+ yield* SubscriptionRef.set(status, "Login successful!");
72
+ });
73
+ },
74
+ },
75
+ [h.input({ type: "email" }), h.button({ type: "submit" }, "Login")],
76
+ );
77
+ ```
78
+
79
+ Read a field imperatively inside the handler with `SubscriptionRef.get`, rather than threading its current value in from outside:
80
+
81
+ ```typescript
82
+ onsubmit: (e) => {
83
+ e.preventDefault();
84
+ return Effect.gen(function* () {
85
+ const u = yield* SubscriptionRef.get(usernameRef);
86
+ const p = yield* SubscriptionRef.get(passwordRef);
87
+ yield* login(u, p);
88
+ });
89
+ },
90
+ ```
91
+
92
+ ## Cross-field validation
93
+
94
+ Combine two fields' `changes` streams with `Stream.zipLatestWith` before mapping to a result:
95
+
96
+ ```typescript
97
+ import { Stream, SubscriptionRef } from "effect";
98
+
99
+ const isValid = Stream.zipLatestWith(
100
+ SubscriptionRef.changes(usernameRef),
101
+ SubscriptionRef.changes(passwordRef),
102
+ (u, p) => u.length > 0 && p.length > 0,
103
+ );
104
+
105
+ h.button({ type: "submit" }, [
106
+ Stream.map(isValid, (valid) => (valid ? "Register" : "Fill all fields")),
107
+ ]);
108
+ ```
109
+
110
+ Nest another `zipLatestWith` to bring in a third field.
111
+
112
+ ## Complete example
113
+
114
+ A login form: one validated email field, a submit button disabled by nothing (validity only changes its label), and a status line. This is the whole file set, copy/paste runnable in a `vite` + `@weftui/core`/`@weftui/dom` project.
115
+
116
+ ```html
117
+ <!-- index.html -->
118
+ <!doctype html>
119
+ <html lang="en">
120
+ <head>
121
+ <meta charset="UTF-8" />
122
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
123
+ <title>Weft form handling demo</title>
124
+ </head>
125
+ <body>
126
+ <div id="root"></div>
127
+ <script type="module" src="/src/main.ts"></script>
128
+ </body>
129
+ </html>
130
+ ```
131
+
132
+ ```typescript
133
+ // src/app.ts
134
+ /**
135
+ * Login form: a validated email field with reactive Schema errors, and an
136
+ * Effect-returning submit handler. Side-effect-free (no mount call), so
137
+ * `main.ts` and any test can import `App` directly.
138
+ */
15
139
  import { h } from "@weftui/core";
16
140
  import { Effect, Result, Schema, Stream, SubscriptionRef } from "effect";
17
141
 
@@ -20,14 +144,13 @@ const Email = Schema.String.pipe(
20
144
  Schema.check(Schema.makeFilter((s) => (s.includes(".") ? undefined : "Must contain a domain"))),
21
145
  );
22
146
 
23
- const LoginForm = () =>
147
+ export const App = () =>
24
148
  Effect.gen(function* () {
25
149
  const email = yield* SubscriptionRef.make("");
26
150
  const status = yield* SubscriptionRef.make<string | null>(null);
27
151
 
28
- // Validation is a stream derived from the field: it re-runs as the user types.
29
152
  const error = Stream.map(SubscriptionRef.changes(email), (value) => {
30
- if (value.length === 0) return null; // don't nag an empty field
153
+ if (value.length === 0) return null;
31
154
  return Result.match(Schema.decodeUnknownResult(Email)(value), {
32
155
  onFailure: (e) => e.message.split(":").pop()?.trim() ?? "Invalid",
33
156
  onSuccess: () => null,
@@ -48,7 +171,7 @@ const LoginForm = () =>
48
171
  [
49
172
  h.input({
50
173
  type: "email",
51
- oninput: (e) => SubscriptionRef.set(email, (e.target as HTMLInputElement).value),
174
+ oninput: (e) => SubscriptionRef.set(email, e.currentTarget.value),
52
175
  }),
53
176
  Stream.map(error, (err) => (err ? h.span({ class: "error-text" }, err) : null)),
54
177
  h.button({ type: "submit" }, "Login"),
@@ -58,19 +181,23 @@ const LoginForm = () =>
58
181
  });
59
182
  ```
60
183
 
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(SubscriptionRef.changes(email), …)` produces an error string (or `null`) on every keystroke. Use [`Schema`](https://effect.website/docs/schema/introduction) to decode: `Schema.decodeUnknownResult(schema)(value)` returns a `Result`, and `Result.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.
184
+ ```typescript
185
+ // src/main.ts
186
+ /**
187
+ * Browser entry: mounts the form demo into #root.
188
+ */
189
+ import { WeftApp } from "@weftui/dom/client";
190
+ import { Effect } from "effect";
191
+ import { App } from "./app";
66
192
 
67
- ## Variations
193
+ const root = document.getElementById("root")!;
68
194
 
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.
195
+ const app = WeftApp.make();
196
+ void Effect.runPromise(WeftApp.mount(app, App(), root));
197
+ ```
71
198
 
72
199
  ## See also
73
200
 
74
201
  - [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
202
+ - [Author Components](https://weftui.dev/docs/how-to/author-components): components with internal state and instance scope
76
203
  - [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
@@ -13,7 +13,7 @@ Return a `Stream<Node>` that emits the loading node first and the resolved node
13
13
 
14
14
  ```typescript
15
15
  import { h } from "@weftui/core";
16
- import { Effect, Stream } from "effect";
16
+ import { Effect, pipe, Stream } from "effect";
17
17
 
18
18
  interface User {
19
19
  id: number;
@@ -32,7 +32,8 @@ const UserCard = ({ id }: { id: number }) =>
32
32
  Stream.concat(
33
33
  Stream.make(h.div({ class: "loading" }, `Loading user ${id}…`)),
34
34
  Stream.fromEffect(
35
- fetchUser(id).pipe(
35
+ pipe(
36
+ fetchUser(id),
36
37
  Effect.flatMap((user) => h.div({ class: "user-card" }, [h.h3(user.name), h.p(user.email)])),
37
38
  Effect.catch((error) => h.div({ class: "error" }, `Error: ${error.message}`)),
38
39
  ),
@@ -47,16 +48,119 @@ const UserCard = ({ id }: { id: number }) =>
47
48
  - **`Effect.catch((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
49
  - **Parallel loading is automatic:** place several async components as siblings and their fetches run concurrently, with no orchestration needed.
49
50
 
51
+ ## Full example
52
+
53
+ The whole file set: `UserCard` mounted twice, one instance failing on purpose (`id: 3`) to show the fallback. Copy/paste runnable in a `vite` + `@weftui/core` + `@weftui/dom` project.
54
+
55
+ ```html
56
+ <!-- index.html -->
57
+ <!doctype html>
58
+ <html lang="en">
59
+ <head>
60
+ <meta charset="UTF-8" />
61
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
62
+ <title>Async user card</title>
63
+ </head>
64
+ <body>
65
+ <div id="root"></div>
66
+ <script type="module" src="/src/main.ts"></script>
67
+ </body>
68
+ </html>
69
+ ```
70
+
71
+ ```typescript
72
+ // src/app.ts
73
+ /**
74
+ * Renders a loading placeholder, then a fetched user card, or an error
75
+ * fallback if the fetch fails. Side-effect-free (no mount call), so
76
+ * `main.ts` and any test can import `App` directly.
77
+ */
78
+ import { h } from "@weftui/core";
79
+ import { Effect, pipe, Stream } from "effect";
80
+
81
+ interface User {
82
+ id: number;
83
+ name: string;
84
+ email: string;
85
+ }
86
+
87
+ const fetchUser = (id: number): Effect.Effect<User, Error> =>
88
+ Effect.gen(function* () {
89
+ yield* Effect.sleep("1000 millis");
90
+ if (id === 3) return yield* Effect.fail(new Error("User not found"));
91
+ return { id, name: `User ${id}`, email: `user${id}@example.com` };
92
+ });
93
+
94
+ const UserCard = ({ id }: { id: number }) =>
95
+ Stream.concat(
96
+ Stream.make(h.div({ class: "loading" }, `Loading user ${id}…`)),
97
+ Stream.fromEffect(
98
+ pipe(
99
+ fetchUser(id),
100
+ Effect.flatMap((user) => h.div({ class: "user-card" }, [h.h3(user.name), h.p(user.email)])),
101
+ Effect.catch((error) => h.div({ class: "error" }, `Error: ${error.message}`)),
102
+ ),
103
+ ),
104
+ );
105
+
106
+ export const App = () => h.div({ id: "app" }, [UserCard({ id: 1 }), UserCard({ id: 3 })]);
107
+ ```
108
+
109
+ ```typescript
110
+ // src/main.ts
111
+ /**
112
+ * Browser entry: mounts the async user card demo into `#root`.
113
+ */
114
+ import { WeftApp } from "@weftui/dom/client";
115
+ import { Effect } from "effect";
116
+ import { App } from "./app";
117
+
118
+ const root = document.getElementById("root")!;
119
+
120
+ const app = WeftApp.make();
121
+ void Effect.runPromise(WeftApp.mount(app, App(), root));
122
+ ```
123
+
50
124
  ## When to reach for a boundary instead
51
125
 
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.
126
+ This is the raw, per-region pattern: each async sibling owns its own loading state and its own fallback. When several async siblings should share **one fallback** and swap in together, wrap them in a suspense boundary instead:
127
+
128
+ ```typescript
129
+ import { Boundary, h } from "@weftui/core";
130
+
131
+ Boundary.suspend({ fallback: h.div({ class: "spinner" }, "Loading…") }, [
132
+ UserCard({ id: 1 }),
133
+ UserCard({ id: 2 }),
134
+ ]);
135
+ ```
136
+
137
+ `Boundary.suspend` waits for **all** children to emit before swapping in, so there's no partial flicker between siblings. See [Boundaries and Suspense](https://weftui.dev/docs/explanation/boundaries-and-suspense) for the full model.
138
+
139
+ When the data must resolve on the **server** and replay on hydrate without a second request, use [`Boundary.rpc`](https://weftui.dev/docs/how-to/load-data-with-rpc) instead. Both boundaries are client-only otherwise; this recipe is purely client-side.
53
140
 
54
141
  ## Blocking on navigation vs streaming in place
55
142
 
56
- The `Stream.concat` placeholder above lives on a **child** node, so it always streams in after mount and never delays a navigation commit. If the component above is a route's leaf, moving the `fetchUser` call into the **body** instead changes that:
143
+ The `Stream.concat` placeholder above lives on a **child** node, so it always streams in after mount and never delays a navigation commit. If the component above is a route's leaf, moving the `fetchUser` call into the leaf's own **body** instead changes that:
144
+
145
+ ```typescript
146
+ import { Component, h } from "@weftui/core";
147
+ import { Router } from "@weftui/router";
148
+ import { Schema } from "effect";
149
+
150
+ // Streaming: fetchUser lives on a child (UserCard); the leaf commits immediately.
151
+ const streamingLeaf = ({ path }: { path: { id: number } }) =>
152
+ h.article([h.h1("User"), UserCard({ id: path.id })]);
153
+
154
+ // Blocking: fetchUser lives in the leaf's own body; navigation waits for it.
155
+ const blockingLeaf = Component.gen(function* () {
156
+ const { id } = yield* Router.params({ id: Schema.NumberFromString });
157
+ const user = yield* fetchUser(id);
158
+ return yield* h.article([h.h1("User"), h.p(user.name)]);
159
+ });
160
+ ```
57
161
 
58
- - **Await in the leaf's own body** is 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** is streaming. The leaf commits immediately and the region fills in place once the effect resolves.
162
+ - **`blockingLeaf`** is 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.
163
+ - **`streamingLeaf`** is streaming. The leaf commits immediately and the region fills in place once `UserCard`'s effect resolves.
60
164
 
61
165
  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.
62
166
 
@@ -7,11 +7,9 @@ description: Boundary.rpc, server-resolved and client-refreshable data; the cont
7
7
 
8
8
  # RPC Data Boundaries
9
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.
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.
11
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.
12
+ ## Quick example
15
13
 
16
14
  ```typescript
17
15
  import { Boundary, h, Subscribable } from "@weftui/core";
@@ -33,9 +31,11 @@ Boundary.rpc(
33
31
  );
34
32
  ```
35
33
 
34
+ `render` receives a reactive [`Resource`](https://weftui.dev/docs/reference/core#resourcea), resolved through the ambient [`AppRpcClientTag`](https://weftui.dev/docs/reference/core#apprpcclienttag) seam that `@weftui/router` provides. The same rpc serves every lifecycle, so SSR-replay, refetch, and client-first mount are one mechanism, not three.
35
+
36
36
  ## The contract / handler split
37
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.
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:
39
39
 
40
40
  ```typescript
41
41
  // data/inventory.ts
@@ -68,11 +68,11 @@ export const StockLive = StockRpcs.toLayer({
68
68
  }).pipe(Layer.provide(InventoryLive));
69
69
  ```
70
70
 
71
- Declare server-only services with [`ServerTag`](https://weftui.dev/docs/reference/core#servertag) (not `Context.Service`) 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.
71
+ The split is enforced structurally by which files each entry imports, not by a bundler plugin. Declare server-only services with [`ServerTag`](https://weftui.dev/docs/reference/core#servertag) (not `Context.Service`) 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
72
 
73
73
  ## Wiring the router
74
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.
75
+ Pass the **merged group** to both sides, plus the **handler Layer** on the server:
76
76
 
77
77
  ```typescript
78
78
  // entry-server.ts: in-process client over the handlers + POST /_eui/rpc endpoint
@@ -100,7 +100,7 @@ void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));
100
100
  - **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. It also exposes an in-process client over the same handlers for SSR resolution, never a network hop.
101
101
  - **Client** ([`RouterLive`](https://weftui.dev/docs/reference/router#routerlive)) provides a network flat rpc client over the merged group, posting to `<origin>/_eui/rpc`.
102
102
 
103
- 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).
103
+ 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.
104
104
 
105
105
  ## The four lifecycles
106
106
 
@@ -111,7 +111,13 @@ In a **router-less mount** there is no `AppRpcClientTag`, so a `Boundary.rpc` re
111
111
  | **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). |
112
112
  | **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. |
113
113
 
114
- 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.
114
+ Because the SSR path seeds `value` await-first, the SSR HTML and the adopted DOM are byte-identical. There is **no fallback flash** on the SSR/hydrate path; `fallback` renders only on a client-first mount:
115
+
116
+ ```typescript
117
+ Boundary.rpc(GetStock, () => ({ id: product.id }), render, {
118
+ fallback: h.p("loading stock…"), // unused on SSR/hydrate; shown only client-first
119
+ });
120
+ ```
115
121
 
116
122
  ## The `Resource` handle
117
123
 
@@ -133,7 +139,7 @@ Because the SSR path seeds `value` await-first (it emits the seed immediately),
133
139
  ]);
134
140
  ```
135
141
 
136
- 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`.
142
+ 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`.
137
143
 
138
144
  ### Channel algebra
139
145
 
@@ -147,22 +153,169 @@ Boundary.rpc<R extends Rpc.Any, C extends Node<any, any>>(
147
153
  ```
148
154
 
149
155
  - **Error** = `render`'s error union plus the rpc's typed `Rpc.Error<R>` (`never` for an rpc with no `error` schema).
150
- - **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.
156
+ - **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.
151
157
 
152
158
  ## Typed-failure replay
153
159
 
154
- If the rpc declares an `error` schema, a resolved rpc **error** on the SSR pass is `errorSchema`-encoded into an inline failure payload. The nearest enclosing **failure `Boundary`** renders its fallback.
160
+ Give the rpc an `error` schema and a resolved rpc **error** on the SSR pass is `errorSchema`-encoded into an inline failure payload:
155
161
 
156
- On the client, `hydrate` decodes that payload and re-raises the same error into the same boundary. The identical fallback DOM is reproduced, flash-free and without re-resolving the rpc (replay, never retry).
162
+ ```typescript
163
+ import { Rpc, RpcGroup } from "effect/unstable/rpc";
164
+ import { Schema } from "effect";
165
+
166
+ export class OutOfStock extends Schema.TaggedError<OutOfStock>()("OutOfStock", {
167
+ reason: Schema.String,
168
+ }) {}
169
+
170
+ export const GetStock = Rpc.make("GetStock", {
171
+ payload: StockKey,
172
+ success: Stock,
173
+ error: OutOfStock,
174
+ });
175
+ ```
176
+
177
+ 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):
157
178
 
158
179
  ```typescript
159
180
  Boundary.catchTag({ tag: "OutOfStock", fallback: (e) => h.p({ class: "error" }, e.reason) }, [
160
- Boundary.rpc(GetStock, () => ({ id: product.id }), (resource) => /* … */),
181
+ Boundary.rpc(
182
+ GetStock,
183
+ () => ({ id: product.id }),
184
+ (resource) => h.p([Stream.map(Subscribable.changes(resource.value), (s) => String(s.units))]),
185
+ ),
161
186
  ]);
162
187
  ```
163
188
 
164
189
  A transport **defect** (no `Cause.findErrorOption`), or an rpc with no `error` schema, is **not** replayed; it propagates: a server-side fallback and a client mismatch.
165
190
 
191
+ ## Full example
192
+
193
+ A `/products/:id` page with a refetchable live-stock `Boundary.rpc`, sealed into a single-route router app. This is the whole file set for the router + rpc parts (drop it alongside a dev server that bridges `entry-server.ts`'s `handler` into Vite or any Web-platform server; see [`examples/router-ssr/server.ts`](https://github.com/stefvw93/weft/blob/main/examples/router-ssr/server.ts) and its co-located [`vite.config.ts`](https://github.com/stefvw93/weft/blob/main/examples/router-ssr/vite.config.ts) for a working one).
194
+
195
+ ```typescript
196
+ // src/data/inventory.ts
197
+ /**
198
+ * The product's live stock: rpc contract + server-only handler. See "The
199
+ * contract / handler split" above for why this file is safe to import from
200
+ * both `app.ts` (contract only) and `entry-server.ts` (contract + handler).
201
+ */
202
+ import { Rpc, RpcGroup } from "effect/unstable/rpc";
203
+ import { Context, Effect, Layer, Schema } from "effect";
204
+
205
+ export const Stock = Schema.Struct({ units: Schema.Number });
206
+ export const StockKey = Schema.Struct({ id: Schema.Number });
207
+ export const GetStock = Rpc.make("GetStock", { payload: StockKey, success: Stock });
208
+ export const StockRpcs = RpcGroup.make(GetStock);
209
+
210
+ class Inventory extends Context.Service<
211
+ Inventory,
212
+ { readonly stockFor: (id: number) => Effect.Effect<typeof Stock.Type> }
213
+ >()("Inventory") {}
214
+
215
+ const InventoryLive = Layer.succeed(Inventory, {
216
+ stockFor: (id) => Effect.succeed({ units: 7 + (id % 5) }),
217
+ });
218
+
219
+ export const StockLive = StockRpcs.toLayer({
220
+ GetStock: (payload) => Effect.flatMap(Inventory, (inv) => inv.stockFor(payload.id)),
221
+ }).pipe(Layer.provide(InventoryLive));
222
+ ```
223
+
224
+ ```typescript
225
+ // src/app.ts
226
+ /**
227
+ * Shared, isomorphic router app: one `/products/:id` page with a refetchable
228
+ * live-stock `Boundary.rpc`. Side-effect-free (no mount/hydrate call), so
229
+ * both entries and any test can import `App` directly.
230
+ */
231
+ import { Boundary, Component, h, Subscribable } from "@weftui/core";
232
+ import { notFound, Router } from "@weftui/router";
233
+ import { Schema, Stream } from "effect";
234
+ import { GetStock } from "./data/inventory";
235
+
236
+ const idParam = { id: Schema.NumberFromString };
237
+
238
+ const productRoute = Router.route("products/:id", {
239
+ path: idParam,
240
+ component: ({ path }) => {
241
+ if (!Number.isFinite(path.id) || path.id < 0) return notFound();
242
+ return Boundary.rpc(
243
+ GetStock,
244
+ () => ({ id: path.id }),
245
+ (resource) =>
246
+ h.section({ id: "page" }, [
247
+ h.h2(`Product ${path.id}`),
248
+ h.p([
249
+ "in stock: ",
250
+ h.span([Stream.map(Subscribable.changes(resource.value), (s) => String(s.units))]),
251
+ ]),
252
+ h.button({ type: "button", onclick: () => resource.refetch }, "Refresh stock"),
253
+ ]),
254
+ { fallback: h.p("loading stock…") },
255
+ );
256
+ },
257
+ });
258
+
259
+ const Shell = Component.gen(function* () {
260
+ const outlet = yield* Router.Outlet;
261
+ return yield* h.div({ id: "app" }, [outlet]);
262
+ });
263
+
264
+ export const App = Router.router(Router.layout({ component: Shell }, [productRoute]), {
265
+ notFound: () => h.section({ id: "page" }, [h.h2("404: page not found")]),
266
+ });
267
+ ```
268
+
269
+ ```typescript
270
+ // src/entry-server.ts
271
+ /**
272
+ * Server entry: renders the matched route to a hydratable HTML document,
273
+ * wiring the rpc's shared group and its server-only handler Layer.
274
+ */
275
+ import { Component, h } from "@weftui/core";
276
+ import { Router } from "@weftui/router";
277
+ import { RouterServer } from "@weftui/router/server";
278
+ import { Effect } from "effect";
279
+ import { App } from "./app";
280
+ import { StockLive, StockRpcs } from "./data/inventory";
281
+
282
+ const rpc = { group: StockRpcs, handlers: StockLive } as const;
283
+
284
+ const documentShell = Component.gen(function* () {
285
+ const app = yield* Router.Outlet;
286
+ return yield* h.html({ lang: "en" }, [
287
+ h.head([h.meta({ charset: "utf-8" }), h.title("Weft shop")]),
288
+ h.body([
289
+ h.div({ id: "root" }, [app]),
290
+ h.script({ type: "module", src: "/src/entry-client.ts" }),
291
+ ]),
292
+ ]);
293
+ });
294
+
295
+ export const render = (url: string): Promise<{ html: string; status: number }> =>
296
+ Effect.runPromise(RouterServer.render(App, { document: documentShell, rpc, url }));
297
+
298
+ export const handler = RouterServer.toWebHandler(App, { document: documentShell, rpc });
299
+ ```
300
+
301
+ ```typescript
302
+ // src/entry-client.ts
303
+ /**
304
+ * Client entry: hydrates the server-rendered markup in `#root`, wiring the
305
+ * rpc's shared group so `resource.refetch` posts to `/_eui/rpc`.
306
+ */
307
+ import { WeftApp } from "@weftui/dom/client";
308
+ import { RouterApp, RouterLive } from "@weftui/router/client";
309
+ import { Effect } from "effect";
310
+ import { App } from "./app";
311
+ import { StockRpcs } from "./data/inventory";
312
+
313
+ const root = document.getElementById("root")!;
314
+
315
+ const app = WeftApp.make(RouterLive(App, { rpc: { group: StockRpcs } }));
316
+ void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));
317
+ ```
318
+
166
319
  ## When to use
167
320
 
168
321
  - **`Boundary.rpc`**: data resolved on the server (behind a server-only service, credential, or private network) and rendered into the initial HTML. It stays **refreshable** on the client over the same rpc.