@weftui/core 0.28.0 → 0.30.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.
Files changed (32) hide show
  1. package/README.md +8 -8
  2. package/dist/{index-Bsh2WLtx.d.ts → index-CX9uEejU.d.ts} +70 -51
  3. package/dist/index.d.ts +68 -119
  4. package/dist/index.js +1 -1
  5. package/dist/types/index.d.ts +1 -1
  6. package/docs/explanation/boundaries-and-suspense.md +39 -20
  7. package/docs/explanation/combinator-api.md +14 -12
  8. package/docs/explanation/reactive-primitives.md +14 -14
  9. package/docs/explanation/rendering-model.md +20 -18
  10. package/docs/explanation/services-and-context.md +35 -21
  11. package/docs/how-to/add-routing.md +361 -60
  12. package/docs/how-to/author-components.md +42 -30
  13. package/docs/how-to/compose-behavior-and-markup.md +144 -0
  14. package/docs/how-to/handle-forms.md +6 -6
  15. package/docs/how-to/load-async-data.md +15 -13
  16. package/docs/how-to/load-data-with-rpc.md +34 -32
  17. package/docs/how-to/provide-services.md +20 -18
  18. package/docs/how-to/render-keyed-lists.md +14 -12
  19. package/docs/how-to/render-on-the-server.md +18 -14
  20. package/docs/how-to/show-navigation-progress.md +12 -10
  21. package/docs/how-to/split-routes-lazily.md +16 -14
  22. package/docs/how-to/style-reactively.md +13 -13
  23. package/docs/how-to/use-element-refs.md +10 -8
  24. package/docs/index.md +20 -18
  25. package/docs/reference/core.md +46 -42
  26. package/docs/reference/dom.md +274 -58
  27. package/docs/reference/router.md +69 -47
  28. package/docs/tutorial/01-your-first-app.md +7 -9
  29. package/docs/tutorial/02-reactivity.md +8 -6
  30. package/docs/tutorial/03-services-and-async.md +10 -6
  31. package/docs/tutorial/04-errors-and-server.md +14 -5
  32. package/package.json +2 -2
@@ -32,7 +32,7 @@ Use a plain function when:
32
32
 
33
33
  ## Components with internal state
34
34
 
35
- When a component needs reactive state, use `Effect.gen` to set it up before building the tree. The component function still runs once the setup happens at mount time:
35
+ When a component needs reactive state, use `Effect.gen` to set it up before building the tree. The component function still runs once. The setup happens at mount time:
36
36
 
37
37
  ```typescript
38
38
  import { h } from "@weftui/core";
@@ -49,25 +49,28 @@ const Counter = () =>
49
49
  });
50
50
  ```
51
51
 
52
- The return type here is `Effect.Effect<Node, never, never>` itself a valid `Node`, so it composes naturally with other tree-building calls. As soon as such a component is reused or takes props, prefer wrapping the same generator in [`Component.gen`](#componentgen--componentmake-for-reusable-components) (below) so the caller's reactive prop and children channels flow into its node type.
52
+ The return type here is `Effect.Effect<Node, never, never>`, itself a valid `Node`, so it composes naturally with other tree-building calls.
53
+
54
+ As soon as such a component is reused or takes props, wrap the same generator in [`Component.gen`](#componentgen--componentmake-for-reusable-components) (below). That way the caller's reactive prop and children channels flow into its node type.
53
55
 
54
56
  ## Component scope and background effects
55
57
 
56
- Every component instance is rendered under its own **instance scope** a child of the
58
+ Every component instance is rendered under its own **instance scope**, a child of the
57
59
  mount scope created fresh for that instance. Anything bound to the instance scope lives
58
- exactly as long as the component is mounted and is torn down automatically when the
59
- component unmounts (or when its root unmounts via `RootHandle.unmount()`). The renderer
60
- provides this scope as the ambient `Scope.Scope` while it evaluates the component body,
61
- so it is already in context when you need it.
60
+ exactly as long as the component is mounted. It is torn down automatically when the
61
+ component unmounts (or when its root unmounts via `RootHandle.unmount()`).
62
+
63
+ The renderer provides this scope as the ambient `Scope.Scope` while it evaluates the
64
+ component body, so it is already in context when you need it.
62
65
 
63
- This matters the moment a component starts **background work** a subscription, an
66
+ This matters the moment a component starts **background work**: a subscription, an
64
67
  observer of a `ref`, a polling timer, anything you `fork`. The rule:
65
68
 
66
69
  > Fork background work with **`Effect.forkScoped`**, never a bare `Effect.forkChild`.
67
70
 
68
71
  `Effect.forkScoped` attaches the fiber to the instance scope, so it keeps running for
69
72
  the component's lifetime and is interrupted on unmount. A bare `Effect.forkChild` instead
70
- attaches the fiber to the component-body fiber the one that runs your `Effect.gen` to
73
+ attaches the fiber to the component-body fiber, the one that runs your `Effect.gen` to
71
74
  produce the tree. That fiber completes the instant the gen returns its node, so the
72
75
  forked work is cancelled almost immediately.
73
76
 
@@ -86,8 +89,8 @@ const AutoFocusInput = () =>
86
89
  Stream.filter(Option.isSome),
87
90
  Stream.take(1),
88
91
  Stream.runForEach((el) => Effect.sync(() => el.value.focus())),
89
- Effect.forkScoped, // ✅ tied to the instance scope survives until unmount
90
- // Effect.forkChild, // ❌ tied to the body fiber interrupted when the gen returns
92
+ Effect.forkScoped, // ✅ tied to the instance scope: survives until unmount
93
+ // Effect.forkChild, // ❌ tied to the body fiber: interrupted when the gen returns
91
94
  );
92
95
 
93
96
  return yield* h.input({ ref: inputRef, type: "text" });
@@ -96,7 +99,7 @@ const AutoFocusInput = () =>
96
99
 
97
100
  You do not manage the scope yourself: you do not create it, close it, or pass it
98
101
  around. `forkScoped` reads it from context, and unmount closes it for you. If you ever
99
- fork outside a component body (rare), you must supply a `Scope.Scope` yourself — the
102
+ fork outside a component body (rare), you must supply a `Scope.Scope` yourself. The
100
103
  type system will tell you, because `forkScoped` carries a `Scope.Scope` requirement.
101
104
 
102
105
  See `examples/element-ref` for the auto-focus, measure, and canvas recipes built on
@@ -106,8 +109,8 @@ this pattern.
106
109
 
107
110
  When you want the caller's reactive prop types to flow into the returned node's type, use one of the `Component` factories. Both have the same call semantics; pick the body style that fits:
108
111
 
109
- - **`Component.make`** body is a plain function returning any `Effect` (typically a `Node`). Use for one-liners and pipe compositions.
110
- - **`Component.gen`** body is a generator. Use when you need `yield*` to set up local state or pull from services.
112
+ - **`Component.make`**: body is a plain function returning any `Effect` (typically a `Node`). Use for one-liners and pipe compositions.
113
+ - **`Component.gen`**: body is a generator. Use when you need `yield*` to set up local state or pull from services.
111
114
 
112
115
  ```typescript
113
116
  import { Component, h, Source } from "@weftui/core";
@@ -125,14 +128,16 @@ const Card = Component.make((props: CardProps) =>
125
128
  );
126
129
  ```
127
130
 
128
- `Source.Source<string>` is Weft's caller-facing prop vocabulary a single type covering a static `string`, a `Stream<string>`, an `Effect<string>`, or a `Subscribable<string>` so you don't hand-write `string | Stream.Stream<string> | …` on every prop. Passing a `Source` straight to `h` (as above) is all you need when the value is just spliced into the tree; the renderer normalizes it.
131
+ `Source.Source<string>` is Weft's caller-facing prop vocabulary: a single type covering a static `string`, a `Stream<string>`, an `Effect<string>`, or a `Subscribable<string>`. You don't hand-write `string | Stream.Stream<string> | …` on every prop.
132
+
133
+ Passing a `Source` straight to `h` (as above) is all you need when the value is just spliced into the tree. The renderer normalizes it.
129
134
 
130
135
  Now the caller's stream types are visible in the returned node:
131
136
 
132
137
  ```typescript
133
138
  declare const titleStream: Stream.Stream<string, never, I18nService>;
134
139
 
135
- // Node<never, I18nService> I18nService requirement flows out
140
+ // Node<never, I18nService>: I18nService requirement flows out
136
141
  const card = Card({ title: titleStream });
137
142
  ```
138
143
 
@@ -140,7 +145,7 @@ Without a `Component` factory, a plain function's return type is fixed at defini
140
145
 
141
146
  ### Body `E`/`R` inference
142
147
 
143
- You don't declare the body's `E`/`R` channels explicitly — they're inferred from the returned (or yielded) effect:
148
+ You don't declare the body's `E`/`R` channels explicitly. They're inferred from the returned (or yielded) effect:
144
149
 
145
150
  - The body's `E`/`R` come from whatever effects appear inside.
146
151
  - The caller's reactive prop channels and reactive children channels are unioned on top at the call site.
@@ -156,7 +161,7 @@ type Component.Children<Input = never> =
156
161
  | ((input: Input) => readonly Renderable[]);
157
162
  ```
158
163
 
159
- The function form is the render-prop / slot pattern the component invokes the function with whatever input it chooses, and the returned array's `E`/`R` propagate out:
164
+ The function form is the render-prop / slot pattern. The component invokes the function with whatever input it chooses, and the returned array's `E`/`R` propagate out:
160
165
 
161
166
  ```typescript
162
167
  const ItemList = Component.make(
@@ -169,7 +174,7 @@ ItemList({ items: ["a", "b"] }, (item) => [h.li(item)]);
169
174
 
170
175
  ## Props typing
171
176
 
172
- For a prop that accepts both static and reactive values, type it as [`Source.Source<T>`](https://weftui.dev/docs/reference/core#source-namespace) rather than hand-writing the union. `Source.Source<T>` **is** that union `T | Stream<T> | Effect<T> | Subscribable<T>` so the caller can pass a plain value or any reactive shape interchangeably, and you write it once:
177
+ For a prop that accepts both static and reactive values, type it as [`Source.Source<T>`](https://weftui.dev/docs/reference/core#source-namespace) rather than hand-writing the union. `Source.Source<T>` **is** that union: `T | Stream<T> | Effect<T> | Subscribable<T>`. The caller can pass a plain value or any reactive shape interchangeably, and you write it once:
173
178
 
174
179
  ```typescript
175
180
  import { Source } from "@weftui/core";
@@ -181,24 +186,31 @@ interface ButtonProps {
181
186
  }
182
187
  ```
183
188
 
184
- When a caller passes a plain string, the component's node type has `never` for that prop's channels. When they pass a `Stream.Stream<string, never, SomeService>`, `SomeService` appears in the `R` channel the extraction is exactly `Source.Success` / `Source.Error` / `Source.Context`.
189
+ When a caller passes a plain string, the component's node type has `never` for that prop's channels. When they pass a `Stream.Stream<string, never, SomeService>`, `SomeService` appears in the `R` channel. The extraction is exactly `Source.Success` / `Source.Error` / `Source.Context`.
185
190
 
186
191
  ### Reading a `Source` in the body
187
192
 
188
- Splicing a `Source` straight into `h` (`[props.label]`) is enough when you only place it in the tree. When the body needs to **read or derive** from the value combine two props, feed a stream operator, drive logic normalize it first with [`Source.toSubscribable`](https://weftui.dev/docs/reference/core#sourcetosubscribablesource-key), which turns any `Source<A>` into an await-first, hot `Subscribable<A>`:
193
+ Splicing a `Source` straight into `h` (`[props.label]`) is enough when you only place it in the tree. When the body needs to **read or derive** from the value (combine two props, feed a stream operator, drive logic), normalize it first with [`Source.toSubscribable`](https://weftui.dev/docs/reference/core#sourcetosubscribablesource-key). It turns any `Source<A>` into an await-first, hot `Subscribable<A>`:
189
194
 
190
195
  ```typescript
191
- import { Component, h, Source } from "@weftui/core";
196
+ import { Component, h, Source, Subscribable } from "@weftui/core";
192
197
  import { Stream } from "effect";
193
198
 
194
199
  const LoudLabel = Component.gen(function* (props: { label: Source.Source<string> }) {
195
200
  const label = yield* Source.toSubscribable(props.label); // Subscribable<string>
196
- // Now derive from it like any Subscribable static, Effect, and Stream inputs all work.
197
- return yield* h.strong([Stream.map(label.changes, (text) => text.toUpperCase())]);
201
+ // Now derive from it like any Subscribable: static, Effect, and Stream inputs all work.
202
+ return yield* h.strong([Stream.map(Subscribable.changes(label), (text) => text.toUpperCase())]);
198
203
  });
199
204
  ```
200
205
 
201
- `toSubscribable` is scoped: a `Stream` prop is pumped by a fiber that terminates with the component's instance scope, an `Effect` prop is memoized, an existing `Subscribable` is threaded through by reference, and a static value emits once. It is the same normalization the renderer applies to props internally — reach for it whenever you need the value as a `Subscribable` instead of leaving it opaque.
206
+ `toSubscribable` is scoped:
207
+
208
+ - A `Stream` prop is pumped by a fiber that terminates with the component's instance scope.
209
+ - An `Effect` prop is memoized.
210
+ - An existing `Subscribable` is threaded through by reference.
211
+ - A static value emits once.
212
+
213
+ It is the same normalization the renderer applies to props internally. Reach for it whenever you need the value as a `Subscribable` instead of leaving it opaque.
202
214
 
203
215
  ## Composing components
204
216
 
@@ -231,7 +243,7 @@ const UserAvatar = Component.gen(function* (props: { userId: string }) {
231
243
  return yield* h.img({ src: user.avatarUrl, alt: user.name });
232
244
  });
233
245
 
234
- // Node<never, UserService> regardless of what the caller passes
246
+ // Node<never, UserService>: regardless of what the caller passes
235
247
  const avatar = UserAvatar({ userId: "123" });
236
248
  ```
237
249
 
@@ -260,7 +272,7 @@ const TableCells = ({ row }: { row: Row }) =>
260
272
 
261
273
  ## See also
262
274
 
263
- - [The Combinator API](https://weftui.dev/docs/explanation/combinator-api) `h`, `h.fragment`, and how `E`/`R` accumulate
264
- - [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives) the `Source` vocabulary props accept
265
- - [Add Routing](https://weftui.dev/docs/how-to/add-routing) route components are `Component` slots
266
- - [`@weftui/core` reference](https://weftui.dev/docs/reference/core) `Component`, `Source`, and the full surface
275
+ - [The Combinator API](https://weftui.dev/docs/explanation/combinator-api): `h`, `h.fragment`, and how `E`/`R` accumulate
276
+ - [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives): the `Source` vocabulary props accept
277
+ - [Add Routing](https://weftui.dev/docs/how-to/add-routing): route components are `Component` slots
278
+ - [`@weftui/core` reference](https://weftui.dev/docs/reference/core): `Component`, `Source`, and the full surface
@@ -0,0 +1,144 @@
1
+ ---
2
+ title: Compose Behavior and Markup
3
+ order: 13
4
+ section: how-to
5
+ description: "Use Props.merge to combine a behavior prop bag with your own markup: chained handlers, ref fan-out, and reactive classes on one element."
6
+ ---
7
+
8
+ # Compose Behavior and Markup
9
+
10
+ **Goal:** share behavior (aria wiring, handlers, refs, reactive state) without
11
+ giving up ownership of the element it applies to.
12
+
13
+ ## The problem
14
+
15
+ Behavior and markup usually want different owners. A dropdown's keyboard
16
+ handling, `aria-expanded` wiring and anchor ref are worth writing once. The
17
+ button itself is yours: your classes, your text, your extra handlers.
18
+
19
+ Object spread cannot combine them. `{ ...behavior, ...mine }` silently drops the
20
+ behavior's `onclick` when you supply your own, and drops its `ref` when you
21
+ supply yours. Nothing warns you.
22
+
23
+ `Props.merge` reconciles both bags instead.
24
+
25
+ ## Behavior as a prop bag
26
+
27
+ A behavior primitive is a plain Effect that yields a prop bag. There is no
28
+ component wrapper and no hook rules, so you can `yield*` it anywhere and hold
29
+ the result.
30
+
31
+ ```ts
32
+ import { Effect, Option, SubscriptionRef } from "effect";
33
+
34
+ const makeDisclosure = () =>
35
+ Effect.gen(function* () {
36
+ const isOpen = yield* SubscriptionRef.make(false);
37
+ const anchor = yield* SubscriptionRef.make(Option.none<HTMLElement>());
38
+
39
+ const trigger = {
40
+ ref: anchor,
41
+ "aria-expanded": SubscriptionRef.changes(isOpen),
42
+ onclick: () => SubscriptionRef.update(isOpen, (open) => !open),
43
+ };
44
+
45
+ return { isOpen, trigger };
46
+ });
47
+ ```
48
+
49
+ `makeDisclosure` returns a plain object, not a `DomProps`-typed value. `merge`
50
+ accepts it as-is: it dispatches on each key's name, not on the bag's declared
51
+ type.
52
+
53
+ ## Merge it onto your element
54
+
55
+ You write the element. The bag merges onto it.
56
+
57
+ ```ts
58
+ import { h } from "@weftui/core";
59
+ import { Props } from "@weftui/dom";
60
+
61
+ const Panel = () =>
62
+ Effect.gen(function* () {
63
+ const disclosure = yield* makeDisclosure();
64
+ const measure = yield* SubscriptionRef.make(Option.none<HTMLElement>());
65
+
66
+ return yield* h.button(
67
+ Props.merge(disclosure.trigger, {
68
+ class: Props.cx("btn", { "btn--open": SubscriptionRef.changes(disclosure.isOpen) }),
69
+ onclick: (ev: MouseEvent) => trackClick(ev),
70
+ ref: measure,
71
+ }),
72
+ "Details",
73
+ );
74
+ });
75
+ ```
76
+
77
+ Three rules earn their keep in that one call:
78
+
79
+ 1. **Handlers chain.** The disclosure toggles, then `trackClick` runs. Both
80
+ always run, and a failure in one does not prevent the other. A broken
81
+ analytics call cannot block the toggle.
82
+ 2. **Refs fan out.** `anchor` and `measure` both receive the element. Spread
83
+ would have kept only one, silently.
84
+ 3. **`cx` takes a reactive condition.** `btn--open` follows `isOpen`, and only
85
+ the class attribute updates.
86
+
87
+ Type an inline handler's event explicitly, as `(ev: MouseEvent)` above. `merge`
88
+ does not know which element the bag will land on, so it cannot infer it.
89
+
90
+ ## Typed errors flow through
91
+
92
+ A handler that fails with a tagged error, or needs a service, keeps both
93
+ channels through the merge. They surface on the component's `Node<E, R>`, so the
94
+ app must provide the service and can catch the error at a boundary.
95
+
96
+ ```ts
97
+ declare const rowBehavior: object;
98
+ declare const itemId: string;
99
+
100
+ const deleteItem = Effect.gen(function* () {
101
+ const files = yield* FileService;
102
+ yield* files.remove(itemId);
103
+ });
104
+
105
+ // The merged node requires FileService and can fail with whatever error
106
+ // `files.remove` declares. Both channels flow through `merge` untouched.
107
+ h.button(Props.merge(rowBehavior, { onclick: () => deleteItem }), "Delete");
108
+ ```
109
+
110
+ ## Two gotchas that differ from spread
111
+
112
+ - **`false` on a handler is an explicit opt-out and wins.** `null` and
113
+ `undefined` mean "not provided", so the other side survives instead.
114
+ - **Every other key is genuinely last-wins.** Forwarding an omitted optional
115
+ prop (`{ id: props.id }`) still overwrites a default with `undefined`, the
116
+ same as `{ ...base, ...override }` would. Guard at the call site if that
117
+ matters.
118
+
119
+ The [reference](https://weftui.dev/docs/reference/dom#propsmerge) has the full per-key rule
120
+ table, including the `style` and reactive-class cases this guide doesn't
121
+ cover.
122
+
123
+ ## When to use
124
+
125
+ Reach for `Props.merge` when two parties contribute props to one element: a
126
+ shared behavior and a caller, or a base variant and a caller's override. For
127
+ a single bag you already control, write the object directly. Merge earns its
128
+ cost only when a key could collide.
129
+
130
+ `Props.merge` is pure: calling it has no side effects and subscribes
131
+ nothing. A merged `class` that turns out reactive is a `Stream` description.
132
+ The renderer subscribes it once the element mounts, the same as any other
133
+ reactive prop.
134
+
135
+ ## See also
136
+
137
+ - [Headless Menu example](https://github.com/stefvw93/weft/tree/main/examples/headless-menu): a full behavior
138
+ primitive (`Menu.trigger`/`popup`/`item`) merged onto consumer-owned markup,
139
+ with handler chaining, ref fan-out, and a service requirement flowing
140
+ through the merge into `Node<E, R>`.
141
+ - [`@weftui/dom` reference](https://weftui.dev/docs/reference/dom): the full per-key rules and `cx` grammar
142
+ - [Use Element Refs](https://weftui.dev/docs/how-to/use-element-refs): the single-ref contract that fan-out builds on
143
+ - [Style Reactively](https://weftui.dev/docs/how-to/style-reactively): per-property style streams and `cx`
144
+ - [The Combinator API](https://weftui.dev/docs/explanation/combinator-api): why elements are plain data you always own
@@ -25,7 +25,7 @@ const LoginForm = () =>
25
25
  const email = yield* SubscriptionRef.make("");
26
26
  const status = yield* SubscriptionRef.make<string | null>(null);
27
27
 
28
- // Validation is a stream derived from the field it re-runs as the user types.
28
+ // Validation is a stream derived from the field: it re-runs as the user types.
29
29
  const error = Stream.map(SubscriptionRef.changes(email), (value) => {
30
30
  if (value.length === 0) return null; // don't nag an empty field
31
31
  return Result.match(Schema.decodeUnknownResult(Email)(value), {
@@ -61,8 +61,8 @@ const LoginForm = () =>
61
61
  ## How it works
62
62
 
63
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.
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.
66
66
 
67
67
  ## Variations
68
68
 
@@ -71,6 +71,6 @@ const LoginForm = () =>
71
71
 
72
72
  ## See also
73
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
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
@@ -2,12 +2,12 @@
2
2
  title: Load Async Data
3
3
  order: 9
4
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.catch all client-side.
5
+ description: Show a loading state then resolved content with Stream.concat, and turn a failed fetch into a fallback node with Effect.catch, all client-side.
6
6
  ---
7
7
 
8
8
  # Load Async Data
9
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**.
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
11
 
12
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.catch`, which maps the error to a fallback node.
13
13
 
@@ -43,28 +43,30 @@ const UserCard = ({ id }: { id: number }) =>
43
43
  ## How it works
44
44
 
45
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.
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
47
  - **`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
- - **Parallel loading is automatic:** place several async components as siblings and their fetches run concurrently no orchestration needed.
48
+ - **Parallel loading is automatic:** place several async components as siblings and their fetches run concurrently, with no orchestration needed.
49
49
 
50
50
  ## When to reach for a boundary instead
51
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.
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
53
 
54
54
  ## Blocking on navigation vs streaming in place
55
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:
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:
57
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.
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.
60
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.
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.
62
+
63
+ 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
64
 
63
65
  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
66
 
65
67
  ## See also
66
68
 
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
69
+ - [Boundaries and Suspense](https://weftui.dev/docs/explanation/boundaries-and-suspense): coordinating multiple async regions
70
+ - [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives): `Stream`/`Effect` as node-producing children
71
+ - [Show Navigation Progress](https://weftui.dev/docs/how-to/show-navigation-progress): the pending signal for the commit-blocking window
72
+ - [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
@@ -2,7 +2,7 @@
2
2
  title: RPC Data Boundaries
3
3
  order: 3
4
4
  section: how-to
5
- description: Boundary.rpc server-resolved, client-refreshable data; the contract/handler split and the Resource handle's four lifecycles.
5
+ description: Boundary.rpc, server-resolved and client-refreshable data; the contract/handler split and the Resource handle's four lifecycles.
6
6
  ---
7
7
 
8
8
  # RPC Data Boundaries
@@ -11,22 +11,22 @@ description: Boundary.rpc — server-resolved, client-refreshable data; the cont
11
11
 
12
12
  ## Overview
13
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.
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
15
 
16
16
  ```typescript
17
- import { Boundary, h } from "@weftui/core";
17
+ import { Boundary, h, Subscribable } from "@weftui/core";
18
18
  import { Stream } from "effect";
19
19
  import { GetStock } from "./data/inventory";
20
20
 
21
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
22
+ GetStock, // the rpc: its _tag + schemas drive the boundary
23
+ () => ({ id: product.id }), // payload thunk: a fresh typed input per call
24
24
  (
25
- resource, // render receives a reactive Resource, not a bare value
25
+ resource, // render: receives a reactive Resource, not a bare value
26
26
  ) =>
27
27
  h.p([
28
28
  "in stock: ",
29
- h.span([Stream.map(resource.value.changes, (s) => String(s.units))]),
29
+ h.span([Stream.map(Subscribable.changes(resource.value), (s) => String(s.units))]),
30
30
  h.button({ type: "button", onclick: () => resource.refetch }, "Refresh"),
31
31
  ]),
32
32
  { fallback: h.p("loading stock…") }, // shown only on a client-first mount
@@ -35,7 +35,7 @@ Boundary.rpc(
35
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. The split is enforced structurally by which files each entry imports, not by a bundler plugin.
39
39
 
40
40
  ```typescript
41
41
  // data/inventory.ts
@@ -49,7 +49,7 @@ export const StockKey = Schema.Struct({ id: Schema.Number });
49
49
  // `_tag` ("GetStock") = the stable boundary id; payload schema = the typed input.
50
50
  export const GetStock = Rpc.make("GetStock", { payload: StockKey, success: Stock });
51
51
 
52
- // The app's merged RpcGroup shared by both the client and server router wiring.
52
+ // The app's merged RpcGroup: shared by both the client and server router wiring.
53
53
  export const StockRpcs = RpcGroup.make(GetStock);
54
54
 
55
55
  // --- Handler (server-only; the client never imports this) ---
@@ -68,14 +68,14 @@ 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
+ 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
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
76
 
77
77
  ```typescript
78
- // entry-server.ts in-process client over the handlers + POST /_eui/rpc endpoint
78
+ // entry-server.ts: in-process client over the handlers + POST /_eui/rpc endpoint
79
79
  import { RouterServer } from "@weftui/router/server";
80
80
  import { StockLive, StockRpcs } from "./data/inventory";
81
81
 
@@ -87,7 +87,7 @@ export const render = (url: string) =>
87
87
  ```
88
88
 
89
89
  ```typescript
90
- // entry-client.ts network client posting to /_eui/rpc
90
+ // entry-client.ts: network client posting to /_eui/rpc
91
91
  import { WeftApp } from "@weftui/dom/client";
92
92
  import { RouterApp, RouterLive } from "@weftui/router/client";
93
93
  import { Effect } from "effect";
@@ -97,7 +97,7 @@ const app = WeftApp.make(RouterLive(App, { rpc: { group: StockRpcs } }));
97
97
  void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));
98
98
  ```
99
99
 
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) **and** exposes an in-process client over the same handlers for SSR resolution never a network hop.
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
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).
@@ -111,29 +111,29 @@ 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 (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.
115
115
 
116
116
  ## The `Resource` handle
117
117
 
118
118
  `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:
119
119
 
120
- | Field | What it gives you |
121
- | --------- | ---------------------------------------------------------------------------------------------------- |
122
- | `value` | A `Subscribable` of the current data seeded with the SSR payload, updated on a successful refetch. |
123
- | `refetch` | An `Effect<void>` that re-resolves the rpc with a fresh `payload()` and pushes the new `value`. |
124
- | `pending` | A `Subscribable<boolean>` `true` while a refetch is in flight. |
125
- | `error` | A `Subscribable<Option<unknown>>` `Some` with the last refetch error (stale-on-error). |
120
+ | Field | What it gives you |
121
+ | --------- | --------------------------------------------------------------------------------------------------- |
122
+ | `value` | A `Subscribable` of the current data: seeded with the SSR payload, updated on a successful refetch. |
123
+ | `refetch` | An `Effect<void>` that re-resolves the rpc with a fresh `payload()` and pushes the new `value`. |
124
+ | `pending` | A `Subscribable<boolean>`: `true` while a refetch is in flight. |
125
+ | `error` | A `Subscribable<Option<unknown>>`: `Some` with the last refetch error (stale-on-error). |
126
126
 
127
127
  ```typescript
128
128
  (resource) =>
129
129
  h.section({ class: "product" }, [
130
- h.span([Stream.map(resource.value.changes, (s) => String(s.units))]),
131
- h.span([Stream.map(resource.pending.changes, (p) => (p ? "refreshing…" : ""))]),
130
+ h.span([Stream.map(Subscribable.changes(resource.value), (s) => String(s.units))]),
131
+ h.span([Stream.map(Subscribable.changes(resource.pending), (p) => (p ? "refreshing…" : ""))]),
132
132
  h.button({ type: "button", onclick: () => resource.refetch }, "Refresh stock"),
133
133
  ]);
134
134
  ```
135
135
 
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`.
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`.
137
137
 
138
138
  ### Channel algebra
139
139
 
@@ -147,11 +147,13 @@ Boundary.rpc<R extends Rpc.Any, C extends Node<any, any>>(
147
147
  ```
148
148
 
149
149
  - **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.
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.
151
151
 
152
152
  ## Typed-failure replay
153
153
 
154
- 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).
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.
155
+
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).
155
157
 
156
158
  ```typescript
157
159
  Boundary.catchTag({ tag: "OutOfStock", fallback: (e) => h.p({ class: "error" }, e.reason) }, [
@@ -159,16 +161,16 @@ Boundary.catchTag({ tag: "OutOfStock", fallback: (e) => h.p({ class: "error" },
159
161
  ]);
160
162
  ```
161
163
 
162
- 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.
164
+ 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.
163
165
 
164
166
  ## When to use
165
167
 
166
- - **`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.
167
- - **`Boundary.suspend`** async data that loads purely on the client; see the [Boundary API](https://weftui.dev/docs/reference/core#boundarysuspend).
168
+ - **`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.
169
+ - **`Boundary.suspend`**: async data that loads purely on the client; see the [Boundary API](https://weftui.dev/docs/reference/core#boundarysuspend).
168
170
 
169
171
  ## See also
170
172
 
171
- - [`Boundary.rpc` API reference](https://weftui.dev/docs/reference/core#boundaryrpc) signature, `Resource`, `RpcOptions`, `AppRpcClientTag`
172
- - [Server-Side Rendering](https://weftui.dev/docs/how-to/render-on-the-server) the SSR + hydration model this builds on
173
- - [Routing](https://weftui.dev/docs/how-to/add-routing) `@weftui/router`, which provides the `AppRpcClientTag` seam
174
- - [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`
173
+ - [`Boundary.rpc` API reference](https://weftui.dev/docs/reference/core#boundaryrpc): signature, `Resource`, `RpcOptions`, `AppRpcClientTag`
174
+ - [Server-Side Rendering](https://weftui.dev/docs/how-to/render-on-the-server): the SSR + hydration model this builds on
175
+ - [Routing](https://weftui.dev/docs/how-to/add-routing): `@weftui/router`, which provides the `AppRpcClientTag` seam
176
+ - [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`