@weftui/router 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,29 +7,26 @@ description: "Use Props.merge to combine a behavior prop bag with your own marku
7
7
 
8
8
  # Compose Behavior and Markup
9
9
 
10
- **Goal:** share behavior (aria wiring, handlers, refs, reactive state) without
11
- giving up ownership of the element it applies to.
10
+ `Props.merge` combines a behavior's prop bag (aria wiring, handlers, refs, reactive state) with markup you own, without either side losing what it contributed.
12
11
 
13
12
  ## The problem
14
13
 
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.
14
+ Object spread can't combine two prop bags safely:
18
15
 
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.
16
+ ```ts
17
+ const merged = { ...behavior, ...mine };
18
+ // mine.onclick replaces behavior.onclick entirely, and mine.ref replaces
19
+ // behavior.ref entirely. Nothing warns you.
20
+ ```
22
21
 
23
- `Props.merge` reconciles both bags instead.
22
+ `Props.merge` reconciles the collision per key instead of silently dropping a side.
24
23
 
25
24
  ## Behavior as a prop bag
26
25
 
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.
26
+ A behavior primitive is a plain Effect that yields a prop bag. There's no component wrapper and no hook rules, so you `yield*` it anywhere and hold the result:
30
27
 
31
28
  ```ts
32
- import { Effect, Option, SubscriptionRef } from "effect";
29
+ import { Effect, Option, Stream, SubscriptionRef } from "effect";
33
30
 
34
31
  const makeDisclosure = () =>
35
32
  Effect.gen(function* () {
@@ -38,7 +35,11 @@ const makeDisclosure = () =>
38
35
 
39
36
  const trigger = {
40
37
  ref: anchor,
41
- "aria-expanded": SubscriptionRef.changes(isOpen),
38
+ // A boolean value renders as presence-only (`setAttribute(name, "")`),
39
+ // which is wrong for `aria-*`. Map to the literal string instead.
40
+ "aria-expanded": Stream.map(SubscriptionRef.changes(isOpen), (open) =>
41
+ open ? ("true" as const) : ("false" as const),
42
+ ),
42
43
  onclick: () => SubscriptionRef.update(isOpen, (open) => !open),
43
44
  };
44
45
 
@@ -46,13 +47,11 @@ const makeDisclosure = () =>
46
47
  });
47
48
  ```
48
49
 
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.
50
+ `makeDisclosure` returns a plain object, not a `DomProps`-typed value. `merge` accepts it as-is: it dispatches on each key's name, not on the bag's declared type.
52
51
 
53
- ## Merge it onto your element
52
+ ## Merge onto your element
54
53
 
55
- You write the element. The bag merges onto it.
54
+ You write the element; the bag merges onto it:
56
55
 
57
56
  ```ts
58
57
  import { h } from "@weftui/core";
@@ -74,24 +73,32 @@ const Panel = () =>
74
73
  });
75
74
  ```
76
75
 
77
- Three rules earn their keep in that one call:
76
+ - **Handlers chain.** The disclosure toggles, then `trackClick` runs; a failure in one never blocks the other.
77
+ - **Refs fan out.** `anchor` and `measure` both receive the element; spread would have kept only one.
78
+ - **`class` takes a reactive condition.** `btn--open` follows `isOpen` through `Props.cx`.
79
+
80
+ Type an inline handler's event explicitly (`(ev: MouseEvent)` above). `merge` doesn't know which element the bag will land on, so it can't infer it.
81
+
82
+ ## Per-key rules
83
+
84
+ | Key | Rule |
85
+ | ------------- | -------------------------------------------------------------------------------------------- |
86
+ | `on*` | Chained left to right. Both bodies run; failures from both sides are aggregated. |
87
+ | `class` | Concatenated. All-static stays a `string`; either side reactive makes it a `Stream<string>`. |
88
+ | `style` | Two per-property objects merge per key, right wins. Any other shape is last-wins. |
89
+ | `ref` | Fan out: concatenates into an array, and every ref receives the element. |
90
+ | anything else | Last-wins. |
78
91
 
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.
92
+ ```ts
93
+ Props.merge({ style: { color: "red" } }, { style: { fontWeight: "bold" } });
94
+ // => { style: { color: "red", fontWeight: "bold" } }
95
+ ```
86
96
 
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.
97
+ A key present on only one side passes through untouched.
89
98
 
90
- ## Typed errors flow through
99
+ ## Typed errors and services flow through
91
100
 
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.
101
+ A handler that fails with a tagged error, or needs a service, keeps both channels through the merge. They surface on the component's `Node<E, R>`, so the app must provide the service and can catch the error at a boundary:
95
102
 
96
103
  ```ts
97
104
  declare const rowBehavior: object;
@@ -109,35 +116,115 @@ h.button(Props.merge(rowBehavior, { onclick: () => deleteItem }), "Delete");
109
116
 
110
117
  ## Two gotchas that differ from spread
111
118
 
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.
119
+ - **`false` on a handler is an explicit opt-out and wins.** `null`/`undefined` mean "not provided", so the other side survives instead:
120
+
121
+ ```ts
122
+ Props.merge({ onclick: () => track() }, { onclick: false }); // handler is off
123
+ ```
124
+
125
+ - **Every other key is genuinely last-wins.** Forwarding an omitted optional prop (`{ id: props.id }`) still overwrites a default with `undefined`, the same as `{ ...base, ...override }` would. Guard at the call site if that matters.
126
+
127
+ The [reference](https://weftui.dev/docs/reference/dom#propsmerge) has the full per-key rule table, including the `style` and reactive-class cases above.
118
128
 
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.
129
+ ## Complete example
130
+
131
+ A disclosure behavior merged onto a caller-owned button, with a click counter and a ref the behavior doesn't know about. This is the whole file set, copy/paste runnable in a `vite` + `@weftui/core`/`@weftui/dom` project.
132
+
133
+ ```html
134
+ <!-- index.html -->
135
+ <!doctype html>
136
+ <html lang="en">
137
+ <head>
138
+ <meta charset="UTF-8" />
139
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
140
+ <title>Compose behavior and markup demo</title>
141
+ </head>
142
+ <body>
143
+ <div id="root"></div>
144
+ <script type="module" src="/src/main.ts"></script>
145
+ </body>
146
+ </html>
147
+ ```
148
+
149
+ ```typescript
150
+ // src/app.ts
151
+ /**
152
+ * Disclosure behavior (open state, anchor ref, toggle handler) merged onto a
153
+ * button the caller owns: its own class, its own click counter, its own ref.
154
+ * Side-effect-free (no mount call), so `main.ts` and any test can import
155
+ * `App` directly.
156
+ */
157
+ import { h } from "@weftui/core";
158
+ import { Props } from "@weftui/dom";
159
+ import { Effect, Option, Stream, SubscriptionRef } from "effect";
160
+
161
+ const makeDisclosure = () =>
162
+ Effect.gen(function* () {
163
+ const isOpen = yield* SubscriptionRef.make(false);
164
+ const anchor = yield* SubscriptionRef.make(Option.none<HTMLElement>());
165
+
166
+ const trigger = {
167
+ ref: anchor,
168
+ "aria-expanded": Stream.map(SubscriptionRef.changes(isOpen), (open) =>
169
+ open ? ("true" as const) : ("false" as const),
170
+ ),
171
+ onclick: () => SubscriptionRef.update(isOpen, (open) => !open),
172
+ };
173
+
174
+ return { isOpen, trigger };
175
+ });
176
+
177
+ export const App = () =>
178
+ Effect.gen(function* () {
179
+ const disclosure = yield* makeDisclosure();
180
+ const measure = yield* SubscriptionRef.make(Option.none<HTMLElement>());
181
+ const clicks = yield* SubscriptionRef.make(0);
182
+
183
+ return yield* h.div({ id: "app" }, [
184
+ h.button(
185
+ Props.merge(disclosure.trigger, {
186
+ class: Props.cx("btn", { "btn--open": SubscriptionRef.changes(disclosure.isOpen) }),
187
+ onclick: () => SubscriptionRef.update(clicks, (n) => n + 1),
188
+ ref: measure,
189
+ }),
190
+ "Details",
191
+ ),
192
+ h.p([
193
+ "clicked ",
194
+ Stream.map(SubscriptionRef.changes(clicks), String),
195
+ " times · ref fan-out: ",
196
+ Stream.map(SubscriptionRef.changes(measure), (captured) =>
197
+ Option.isSome(captured) ? "captured" : "pending",
198
+ ),
199
+ ]),
200
+ ]);
201
+ });
202
+ ```
203
+
204
+ ```typescript
205
+ // src/main.ts
206
+ /**
207
+ * Browser entry: mounts the demo into #root.
208
+ */
209
+ import { WeftApp } from "@weftui/dom/client";
210
+ import { Effect } from "effect";
211
+ import { App } from "./app";
212
+
213
+ const root = document.getElementById("root")!;
214
+
215
+ const app = WeftApp.make();
216
+ void Effect.runPromise(WeftApp.mount(app, App(), root));
217
+ ```
122
218
 
123
219
  ## When to use
124
220
 
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.
221
+ Reach for `Props.merge` when two parties contribute props to one element: a shared behavior and a caller, or a base variant and a caller's override. For a single bag you already control, write the object directly. Merge only earns its cost when a key could collide.
129
222
 
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.
223
+ `Props.merge` is pure: calling it has no side effects and subscribes nothing. A merged `class` that turns out reactive is a `Stream` description, not a live subscription. The renderer subscribes it once the element mounts, the same as any other reactive prop.
134
224
 
135
225
  ## See also
136
226
 
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>`.
227
+ - [Headless Menu example](https://github.com/stefvw93/weft/tree/main/examples/headless-menu): a full behavior primitive (`Menu.trigger`/`popup`/`item`) merged onto consumer-owned markup, with handler chaining, ref fan-out, and a service requirement flowing through the merge into `Node<E, R>`.
141
228
  - [`@weftui/dom` reference](https://weftui.dev/docs/reference/dom): the full per-key rules and `cx` grammar
142
229
  - [Use Element Refs](https://weftui.dev/docs/how-to/use-element-refs): the single-ref contract that fan-out builds on
143
230
  - [Style Reactively](https://weftui.dev/docs/how-to/style-reactively): per-property style streams and `cx`
@@ -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