@weftui/core 0.30.0 → 0.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/{index-CX9uEejU.d.ts → index-B-dPfhKZ.d.ts} +13 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/types/index.d.ts +1 -1
- 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 +3 -9
- 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/reference/router.md +2 -2
- package/docs/tutorial/01-your-first-app.md +45 -15
- 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,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
|
|
|
@@ -7,11 +7,9 @@ description: Boundary.rpc, server-resolved and client-refreshable data; the cont
|
|
|
7
7
|
|
|
8
8
|
# RPC Data Boundaries
|
|
9
9
|
|
|
10
|
-
`Boundary.rpc` is Weft's primitive for **server-resolved, client-refreshable** data. One [`Rpc`](https://github.com/Effect-TS/effect/tree/main/packages/rpc) from the app's merged `RpcGroup` backs a single render boundary across four lifecycles: server-side render, hydrate-replay, client refetch, and client-first SPA mount.
|
|
10
|
+
`Boundary.rpc` is Weft's primitive for **server-resolved, client-refreshable** data. One [`Rpc`](https://github.com/Effect-TS/effect/tree/main/packages/rpc) from the app's merged `RpcGroup` backs a single render boundary across four lifecycles: server-side render, hydrate-replay, client refetch, and client-first SPA mount.
|
|
11
11
|
|
|
12
|
-
##
|
|
13
|
-
|
|
14
|
-
A `Boundary.rpc` is a **thin consumer**. It carries an rpc, a payload thunk, and a `render` that receives a reactive [`Resource`](https://weftui.dev/docs/reference/core#resourcea). The renderer resolves the rpc through the ambient [`AppRpcClientTag`](https://weftui.dev/docs/reference/core#apprpcclienttag) seam (provided by `@weftui/router`). The same rpc serves every lifecycle, so SSR-replay, refetch, and client-first mount are one mechanism, not three.
|
|
12
|
+
## Quick example
|
|
15
13
|
|
|
16
14
|
```typescript
|
|
17
15
|
import { Boundary, h, Subscribable } from "@weftui/core";
|
|
@@ -33,9 +31,11 @@ Boundary.rpc(
|
|
|
33
31
|
);
|
|
34
32
|
```
|
|
35
33
|
|
|
34
|
+
`render` receives a reactive [`Resource`](https://weftui.dev/docs/reference/core#resourcea), resolved through the ambient [`AppRpcClientTag`](https://weftui.dev/docs/reference/core#apprpcclienttag) seam that `@weftui/router` provides. The same rpc serves every lifecycle, so SSR-replay, refetch, and client-first mount are one mechanism, not three.
|
|
35
|
+
|
|
36
36
|
## The contract / handler split
|
|
37
37
|
|
|
38
|
-
The rpc **contract** (pure Schema) is shared with the client. The rpc **handler**, the only code that touches server-only services, lives in a Layer the client never imports. Tree-shaking keeps it and its transitive imports out of the browser bundle
|
|
38
|
+
The rpc **contract** (pure Schema) is shared with the client. The rpc **handler**, the only code that touches server-only services, lives in a Layer the client never imports. Tree-shaking keeps it and its transitive imports out of the browser bundle:
|
|
39
39
|
|
|
40
40
|
```typescript
|
|
41
41
|
// data/inventory.ts
|
|
@@ -68,11 +68,11 @@ export const StockLive = StockRpcs.toLayer({
|
|
|
68
68
|
}).pipe(Layer.provide(InventoryLive));
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
Declare server-only services with [`ServerTag`](https://weftui.dev/docs/reference/core#servertag) (not `Context.Service`) when they might be referenced from universal code
|
|
71
|
+
The split is enforced structurally by which files each entry imports, not by a bundler plugin. Declare server-only services with [`ServerTag`](https://weftui.dev/docs/reference/core#servertag) (not `Context.Service`) when they might be referenced from universal code: the brand makes a leak into `render` a compile error at the `hydrate` call site, rather than a runtime surprise.
|
|
72
72
|
|
|
73
73
|
## Wiring the router
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
Pass the **merged group** to both sides, plus the **handler Layer** on the server:
|
|
76
76
|
|
|
77
77
|
```typescript
|
|
78
78
|
// entry-server.ts: in-process client over the handlers + POST /_eui/rpc endpoint
|
|
@@ -100,7 +100,7 @@ void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));
|
|
|
100
100
|
- **Server** ([`RouterServer`](https://weftui.dev/docs/reference/router#routerserver)) mounts the handler Layer at `POST /_eui/rpc`, so a client refetch re-runs it on the server. It also exposes an in-process client over the same handlers for SSR resolution, never a network hop.
|
|
101
101
|
- **Client** ([`RouterLive`](https://weftui.dev/docs/reference/router#routerlive)) provides a network flat rpc client over the merged group, posting to `<origin>/_eui/rpc`.
|
|
102
102
|
|
|
103
|
-
In a **router-less mount** there is no `AppRpcClientTag`, so a `Boundary.rpc` resolves to a typed, descriptive "needs router/rpc" error
|
|
103
|
+
In a **router-less mount** there is no `AppRpcClientTag`, so a `Boundary.rpc` resolves to a typed, descriptive "needs router/rpc" error, not a defect.
|
|
104
104
|
|
|
105
105
|
## The four lifecycles
|
|
106
106
|
|
|
@@ -111,7 +111,13 @@ In a **router-less mount** there is no `AppRpcClientTag`, so a `Boundary.rpc` re
|
|
|
111
111
|
| **Refetch** | `resource.refetch` | Call the rpc again over `POST /_eui/rpc` (re-runs the handler on the server), patch the subtree in place (stale-on-error). |
|
|
112
112
|
| **Client-first mount** | SPA nav into a boundary with no payload | Render `options.fallback`, fork the rpc call, swap in `render(resource)` once it resolves. |
|
|
113
113
|
|
|
114
|
-
Because the SSR path seeds `value` await-first
|
|
114
|
+
Because the SSR path seeds `value` await-first, the SSR HTML and the adopted DOM are byte-identical. There is **no fallback flash** on the SSR/hydrate path; `fallback` renders only on a client-first mount:
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
Boundary.rpc(GetStock, () => ({ id: product.id }), render, {
|
|
118
|
+
fallback: h.p("loading stock…"), // unused on SSR/hydrate; shown only client-first
|
|
119
|
+
});
|
|
120
|
+
```
|
|
115
121
|
|
|
116
122
|
## The `Resource` handle
|
|
117
123
|
|
|
@@ -133,7 +139,7 @@ Because the SSR path seeds `value` await-first (it emits the seed immediately),
|
|
|
133
139
|
]);
|
|
134
140
|
```
|
|
135
141
|
|
|
136
|
-
Wire `refetch` to an event with `onclick: () => resource.refetch`. The handler returns the Effect, which the renderer runs in a detached fiber. A failed refetch leaves the previous `value` intact (stale-on-error)
|
|
142
|
+
Wire `refetch` to an event with `onclick: () => resource.refetch`. The handler returns the Effect, which the renderer runs in a detached fiber. A failed refetch leaves the previous `value` intact (stale-on-error): it does **not** unmount the subtree or raise into a failure `Boundary`.
|
|
137
143
|
|
|
138
144
|
### Channel algebra
|
|
139
145
|
|
|
@@ -147,22 +153,169 @@ Boundary.rpc<R extends Rpc.Any, C extends Node<any, any>>(
|
|
|
147
153
|
```
|
|
148
154
|
|
|
149
155
|
- **Error** = `render`'s error union plus the rpc's typed `Rpc.Error<R>` (`never` for an rpc with no `error` schema).
|
|
150
|
-
- **Requirement** = exactly `render`'s `R`, **untouched
|
|
156
|
+
- **Requirement** = exactly `render`'s `R`, **untouched**: there is no `provide`/`RServer` to discharge (the handler lives in the rpc Layer) and no `Exclude` is applied. A server-only tag leaked into `render` stays in `R`, where `hydrate`'s `AssertNoServerOnly` rejects it.
|
|
151
157
|
|
|
152
158
|
## Typed-failure replay
|
|
153
159
|
|
|
154
|
-
|
|
160
|
+
Give the rpc an `error` schema and a resolved rpc **error** on the SSR pass is `errorSchema`-encoded into an inline failure payload:
|
|
155
161
|
|
|
156
|
-
|
|
162
|
+
```typescript
|
|
163
|
+
import { Rpc, RpcGroup } from "effect/unstable/rpc";
|
|
164
|
+
import { Schema } from "effect";
|
|
165
|
+
|
|
166
|
+
export class OutOfStock extends Schema.TaggedError<OutOfStock>()("OutOfStock", {
|
|
167
|
+
reason: Schema.String,
|
|
168
|
+
}) {}
|
|
169
|
+
|
|
170
|
+
export const GetStock = Rpc.make("GetStock", {
|
|
171
|
+
payload: StockKey,
|
|
172
|
+
success: Stock,
|
|
173
|
+
error: OutOfStock,
|
|
174
|
+
});
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The nearest enclosing **failure `Boundary`** renders its fallback. On the client, `hydrate` decodes that payload and re-raises the same error into the same boundary, reproducing the identical fallback DOM, flash-free and without re-resolving the rpc (replay, never retry):
|
|
157
178
|
|
|
158
179
|
```typescript
|
|
159
180
|
Boundary.catchTag({ tag: "OutOfStock", fallback: (e) => h.p({ class: "error" }, e.reason) }, [
|
|
160
|
-
Boundary.rpc(
|
|
181
|
+
Boundary.rpc(
|
|
182
|
+
GetStock,
|
|
183
|
+
() => ({ id: product.id }),
|
|
184
|
+
(resource) => h.p([Stream.map(Subscribable.changes(resource.value), (s) => String(s.units))]),
|
|
185
|
+
),
|
|
161
186
|
]);
|
|
162
187
|
```
|
|
163
188
|
|
|
164
189
|
A transport **defect** (no `Cause.findErrorOption`), or an rpc with no `error` schema, is **not** replayed; it propagates: a server-side fallback and a client mismatch.
|
|
165
190
|
|
|
191
|
+
## Full example
|
|
192
|
+
|
|
193
|
+
A `/products/:id` page with a refetchable live-stock `Boundary.rpc`, sealed into a single-route router app. This is the whole file set for the router + rpc parts (drop it alongside a dev server that bridges `entry-server.ts`'s `handler` into Vite or any Web-platform server; see [`examples/router-ssr/server.ts`](https://github.com/stefvw93/weft/blob/main/examples/router-ssr/server.ts) and its co-located [`vite.config.ts`](https://github.com/stefvw93/weft/blob/main/examples/router-ssr/vite.config.ts) for a working one).
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
// src/data/inventory.ts
|
|
197
|
+
/**
|
|
198
|
+
* The product's live stock: rpc contract + server-only handler. See "The
|
|
199
|
+
* contract / handler split" above for why this file is safe to import from
|
|
200
|
+
* both `app.ts` (contract only) and `entry-server.ts` (contract + handler).
|
|
201
|
+
*/
|
|
202
|
+
import { Rpc, RpcGroup } from "effect/unstable/rpc";
|
|
203
|
+
import { Context, Effect, Layer, Schema } from "effect";
|
|
204
|
+
|
|
205
|
+
export const Stock = Schema.Struct({ units: Schema.Number });
|
|
206
|
+
export const StockKey = Schema.Struct({ id: Schema.Number });
|
|
207
|
+
export const GetStock = Rpc.make("GetStock", { payload: StockKey, success: Stock });
|
|
208
|
+
export const StockRpcs = RpcGroup.make(GetStock);
|
|
209
|
+
|
|
210
|
+
class Inventory extends Context.Service<
|
|
211
|
+
Inventory,
|
|
212
|
+
{ readonly stockFor: (id: number) => Effect.Effect<typeof Stock.Type> }
|
|
213
|
+
>()("Inventory") {}
|
|
214
|
+
|
|
215
|
+
const InventoryLive = Layer.succeed(Inventory, {
|
|
216
|
+
stockFor: (id) => Effect.succeed({ units: 7 + (id % 5) }),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
export const StockLive = StockRpcs.toLayer({
|
|
220
|
+
GetStock: (payload) => Effect.flatMap(Inventory, (inv) => inv.stockFor(payload.id)),
|
|
221
|
+
}).pipe(Layer.provide(InventoryLive));
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
// src/app.ts
|
|
226
|
+
/**
|
|
227
|
+
* Shared, isomorphic router app: one `/products/:id` page with a refetchable
|
|
228
|
+
* live-stock `Boundary.rpc`. Side-effect-free (no mount/hydrate call), so
|
|
229
|
+
* both entries and any test can import `App` directly.
|
|
230
|
+
*/
|
|
231
|
+
import { Boundary, Component, h, Subscribable } from "@weftui/core";
|
|
232
|
+
import { notFound, Router } from "@weftui/router";
|
|
233
|
+
import { Schema, Stream } from "effect";
|
|
234
|
+
import { GetStock } from "./data/inventory";
|
|
235
|
+
|
|
236
|
+
const idParam = { id: Schema.NumberFromString };
|
|
237
|
+
|
|
238
|
+
const productRoute = Router.route("products/:id", {
|
|
239
|
+
path: idParam,
|
|
240
|
+
component: ({ path }) => {
|
|
241
|
+
if (!Number.isFinite(path.id) || path.id < 0) return notFound();
|
|
242
|
+
return Boundary.rpc(
|
|
243
|
+
GetStock,
|
|
244
|
+
() => ({ id: path.id }),
|
|
245
|
+
(resource) =>
|
|
246
|
+
h.section({ id: "page" }, [
|
|
247
|
+
h.h2(`Product ${path.id}`),
|
|
248
|
+
h.p([
|
|
249
|
+
"in stock: ",
|
|
250
|
+
h.span([Stream.map(Subscribable.changes(resource.value), (s) => String(s.units))]),
|
|
251
|
+
]),
|
|
252
|
+
h.button({ type: "button", onclick: () => resource.refetch }, "Refresh stock"),
|
|
253
|
+
]),
|
|
254
|
+
{ fallback: h.p("loading stock…") },
|
|
255
|
+
);
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const Shell = Component.gen(function* () {
|
|
260
|
+
const outlet = yield* Router.Outlet;
|
|
261
|
+
return yield* h.div({ id: "app" }, [outlet]);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
export const App = Router.router(Router.layout({ component: Shell }, [productRoute]), {
|
|
265
|
+
notFound: () => h.section({ id: "page" }, [h.h2("404: page not found")]),
|
|
266
|
+
});
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
// src/entry-server.ts
|
|
271
|
+
/**
|
|
272
|
+
* Server entry: renders the matched route to a hydratable HTML document,
|
|
273
|
+
* wiring the rpc's shared group and its server-only handler Layer.
|
|
274
|
+
*/
|
|
275
|
+
import { Component, h } from "@weftui/core";
|
|
276
|
+
import { Router } from "@weftui/router";
|
|
277
|
+
import { RouterServer } from "@weftui/router/server";
|
|
278
|
+
import { Effect } from "effect";
|
|
279
|
+
import { App } from "./app";
|
|
280
|
+
import { StockLive, StockRpcs } from "./data/inventory";
|
|
281
|
+
|
|
282
|
+
const rpc = { group: StockRpcs, handlers: StockLive } as const;
|
|
283
|
+
|
|
284
|
+
const documentShell = Component.gen(function* () {
|
|
285
|
+
const app = yield* Router.Outlet;
|
|
286
|
+
return yield* h.html({ lang: "en" }, [
|
|
287
|
+
h.head([h.meta({ charset: "utf-8" }), h.title("Weft shop")]),
|
|
288
|
+
h.body([
|
|
289
|
+
h.div({ id: "root" }, [app]),
|
|
290
|
+
h.script({ type: "module", src: "/src/entry-client.ts" }),
|
|
291
|
+
]),
|
|
292
|
+
]);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
export const render = (url: string): Promise<{ html: string; status: number }> =>
|
|
296
|
+
Effect.runPromise(RouterServer.render(App, { document: documentShell, rpc, url }));
|
|
297
|
+
|
|
298
|
+
export const handler = RouterServer.toWebHandler(App, { document: documentShell, rpc });
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
// src/entry-client.ts
|
|
303
|
+
/**
|
|
304
|
+
* Client entry: hydrates the server-rendered markup in `#root`, wiring the
|
|
305
|
+
* rpc's shared group so `resource.refetch` posts to `/_eui/rpc`.
|
|
306
|
+
*/
|
|
307
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
308
|
+
import { RouterApp, RouterLive } from "@weftui/router/client";
|
|
309
|
+
import { Effect } from "effect";
|
|
310
|
+
import { App } from "./app";
|
|
311
|
+
import { StockRpcs } from "./data/inventory";
|
|
312
|
+
|
|
313
|
+
const root = document.getElementById("root")!;
|
|
314
|
+
|
|
315
|
+
const app = WeftApp.make(RouterLive(App, { rpc: { group: StockRpcs } }));
|
|
316
|
+
void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));
|
|
317
|
+
```
|
|
318
|
+
|
|
166
319
|
## When to use
|
|
167
320
|
|
|
168
321
|
- **`Boundary.rpc`**: data resolved on the server (behind a server-only service, credential, or private network) and rendered into the initial HTML. It stays **refreshable** on the client over the same rpc.
|