@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.
- package/docs/explanation/boundaries-and-suspense.md +36 -6
- package/docs/explanation/combinator-api.md +17 -11
- package/docs/explanation/reactive-primitives.md +40 -13
- package/docs/explanation/rendering-model.md +14 -2
- package/docs/how-to/add-routing.md +2 -8
- package/docs/how-to/author-components.md +69 -103
- package/docs/how-to/compose-behavior-and-markup.md +141 -54
- package/docs/how-to/handle-forms.md +142 -15
- package/docs/how-to/load-async-data.md +110 -6
- package/docs/how-to/load-data-with-rpc.md +167 -14
- package/docs/how-to/provide-services.md +84 -1
- package/docs/how-to/render-keyed-lists.md +116 -10
- package/docs/how-to/render-on-the-server.md +111 -13
- package/docs/how-to/show-navigation-progress.md +118 -12
- package/docs/how-to/split-routes-lazily.md +106 -3
- package/docs/how-to/style-reactively.md +141 -13
- package/docs/how-to/use-element-refs.md +126 -8
- package/docs/reference/core.md +20 -0
- package/docs/reference/dom.md +43 -0
- package/docs/tutorial/01-your-first-app.md +43 -13
- package/docs/tutorial/02-reactivity.md +29 -30
- package/docs/tutorial/03-services-and-async.md +89 -38
- package/docs/tutorial/04-errors-and-server.md +41 -25
- package/package.json +3 -3
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
52
|
+
## Merge onto your element
|
|
54
53
|
|
|
55
|
-
You write the element
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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`
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
|
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,
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
193
|
+
const root = document.getElementById("root")!;
|
|
68
194
|
|
|
69
|
-
|
|
70
|
-
|
|
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):
|
|
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
|
-
|
|
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
|
|
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
|
-
-
|
|
59
|
-
-
|
|
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
|
|