@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,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.TaggedErrorClass<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.
|
|
@@ -11,7 +11,7 @@ description: "Provide plain and scoped Layers to a WeftApp: app layers for the c
|
|
|
11
11
|
|
|
12
12
|
## Recipe 1: app layers
|
|
13
13
|
|
|
14
|
-
Pass the layer to `WeftApp.make
|
|
14
|
+
Pass the layer to `WeftApp.make`: the common case, and it needs nothing else. The layer builds lazily on first mount; every component, event handler, and stream subscription in every root mounted from `app` can read it.
|
|
15
15
|
|
|
16
16
|
```typescript
|
|
17
17
|
import { WeftApp } from "@weftui/dom/client";
|
|
@@ -25,6 +25,20 @@ const app = WeftApp.make(ThemeServiceLive);
|
|
|
25
25
|
void Effect.runPromise(WeftApp.mount(app, App(), root));
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
A component reads the service the same way anywhere else in Effect: `yield* Service`.
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
import { h } from "@weftui/core";
|
|
32
|
+
import { Effect } from "effect";
|
|
33
|
+
import { ThemeService } from "./theme-service";
|
|
34
|
+
|
|
35
|
+
export const App = () =>
|
|
36
|
+
Effect.gen(function* () {
|
|
37
|
+
const theme = yield* ThemeService;
|
|
38
|
+
return yield* h.div({ class: `app app--${theme.mode}` }, [h.p(`Theme: ${theme.mode}`)]);
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
28
42
|
## Recipe 2: scoped layers just work
|
|
29
43
|
|
|
30
44
|
A **scoped** layer (`Layer.effect` backed by `acquireRelease`, or anything else that owns a subscription, listener, or registry) needs nothing different from Recipe 1. The app owns one lazy `ManagedRuntime`. The layer builds on first mount and releases only at `WeftApp.dispose(app)`, not when any individual mount's render effect resolves.
|
|
@@ -85,9 +99,78 @@ const acquireApp = Effect.acquireRelease(
|
|
|
85
99
|
|
|
86
100
|
`acquireApp` yields a `WeftApp` and registers `WeftApp.dispose` as a finalizer on whatever scope the surrounding effect runs in. Closing that scope tears the app down the same way `WeftApp.dispose` normally would (roots, then layers, then the error hub).
|
|
87
101
|
|
|
102
|
+
## Complete example
|
|
103
|
+
|
|
104
|
+
Recipe 1 end to end: a `ThemeService` defined with `Context.Service`, provided through `WeftApp.make`, and read by `App` with `yield* Service`. This is the whole file set, copy/paste runnable in a `vite` project.
|
|
105
|
+
|
|
106
|
+
```html
|
|
107
|
+
<!-- index.html -->
|
|
108
|
+
<!doctype html>
|
|
109
|
+
<html lang="en">
|
|
110
|
+
<head>
|
|
111
|
+
<meta charset="UTF-8" />
|
|
112
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
113
|
+
<title>Provide services demo</title>
|
|
114
|
+
</head>
|
|
115
|
+
<body>
|
|
116
|
+
<div id="root"></div>
|
|
117
|
+
<script type="module" src="/src/main.ts"></script>
|
|
118
|
+
</body>
|
|
119
|
+
</html>
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
// src/theme-service.ts
|
|
124
|
+
/** The active theme, provided app-wide by `ThemeServiceLive`. */
|
|
125
|
+
import { Context, Layer } from "effect";
|
|
126
|
+
|
|
127
|
+
export class ThemeService extends Context.Service<
|
|
128
|
+
ThemeService,
|
|
129
|
+
{ readonly mode: "light" | "dark" }
|
|
130
|
+
>()("ThemeService") {}
|
|
131
|
+
|
|
132
|
+
export const ThemeServiceLive = Layer.succeed(ThemeService, { mode: "dark" });
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
// src/app.ts
|
|
137
|
+
/**
|
|
138
|
+
* Reads `ThemeService` from the app layer and renders the active mode.
|
|
139
|
+
* Side-effect-free (no mount call), so `main.ts` and any test can import `App`.
|
|
140
|
+
*/
|
|
141
|
+
import { h } from "@weftui/core";
|
|
142
|
+
import { Effect } from "effect";
|
|
143
|
+
import { ThemeService } from "./theme-service";
|
|
144
|
+
|
|
145
|
+
export const App = () =>
|
|
146
|
+
Effect.gen(function* () {
|
|
147
|
+
const theme = yield* ThemeService;
|
|
148
|
+
return yield* h.div({ class: `app app--${theme.mode}` }, [
|
|
149
|
+
h.h1("Provide Services demo"),
|
|
150
|
+
h.p(`Theme: ${theme.mode}`),
|
|
151
|
+
]);
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
// src/main.ts
|
|
157
|
+
/** Browser entry: mounts `App` with `ThemeServiceLive` provided through the app layer. */
|
|
158
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
159
|
+
import { Effect } from "effect";
|
|
160
|
+
import { App } from "./app";
|
|
161
|
+
import { ThemeServiceLive } from "./theme-service";
|
|
162
|
+
|
|
163
|
+
const root = document.getElementById("root")!;
|
|
164
|
+
|
|
165
|
+
const app = WeftApp.make(ThemeServiceLive);
|
|
166
|
+
void Effect.runPromise(WeftApp.mount(app, App(), root));
|
|
167
|
+
```
|
|
168
|
+
|
|
88
169
|
## Anti-pattern: `Effect.provide` around the mount call
|
|
89
170
|
|
|
90
171
|
```typescript
|
|
172
|
+
import { Effect, pipe } from "effect";
|
|
173
|
+
|
|
91
174
|
// ❌ does nothing useful: WeftApp.mount's R is always `never`, and services
|
|
92
175
|
// come exclusively from the app layer: a wrapped Effect.provide never
|
|
93
176
|
// reaches components, handlers, or stream subscriptions
|
|
@@ -7,9 +7,7 @@ description: Render a reactive collection with List.each so reordering, insertin
|
|
|
7
7
|
|
|
8
8
|
# Render Keyed Lists
|
|
9
9
|
|
|
10
|
-
**Goal:** render a list
|
|
11
|
-
|
|
12
|
-
Use [`List.each`](https://weftui.dev/docs/reference/core#listeach), the keyed-list combinator. It renders each item **once per key** and reconciles across emissions. A reorder _moves_ existing DOM nodes, an insert adds one, a remove drops one, and untouched rows are left entirely alone.
|
|
10
|
+
**Goal:** render a list that reorders, inserts, or removes items over time, without rebuilding the whole region and losing focus, scroll, or input state in the surviving rows.
|
|
13
11
|
|
|
14
12
|
```typescript
|
|
15
13
|
import { h, List, Subscribable } from "@weftui/core";
|
|
@@ -25,18 +23,32 @@ h.ul([
|
|
|
25
23
|
]);
|
|
26
24
|
```
|
|
27
25
|
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
[`List.each`](https://weftui.dev/docs/reference/core#listeach) renders each item **once per key** and reconciles across emissions. A reorder moves existing DOM nodes, an insert adds one, a remove drops one, and untouched rows are left alone.
|
|
27
|
+
|
|
28
|
+
## Options
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
interface List.Options<S, K> {
|
|
32
|
+
readonly of: S; // Iterable<T>, or an Effect/Stream/Subscribable of one
|
|
33
|
+
readonly by?: (item: ItemOf<S>, index: number) => K; // reconciliation key
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- **`of`**: the list source. Each emission is materialized to an array to fix order, then reconciled by key.
|
|
38
|
+
- **`by`**: projects each item to its reconciliation key, compared via Effect's `Equal`/`Hash`. Omit it and the item itself is the key (structural for `Data`, by reference otherwise).
|
|
30
39
|
|
|
31
|
-
## Why not `map
|
|
40
|
+
## Why not `map`
|
|
32
41
|
|
|
33
|
-
|
|
42
|
+
```typescript
|
|
43
|
+
// Rebuilds every row on every emission: a new children array each time.
|
|
44
|
+
Stream.map(Subscribable.changes(rows), (rs) => rs.map((r) => h.li(r.name)));
|
|
45
|
+
```
|
|
34
46
|
|
|
35
|
-
`List.each` reconciles by key instead, so DOM identity (and the focus/scroll/typed-input state attached to it) survives across updates.
|
|
47
|
+
The renderer diffs children by position, so a new array means every row's DOM node is recreated, even the ones that didn't move. `List.each` reconciles by key instead, so DOM identity (and the focus/scroll/typed-input state attached to it) survives across updates.
|
|
36
48
|
|
|
37
49
|
## Refresh a row's content
|
|
38
50
|
|
|
39
|
-
|
|
51
|
+
`render` runs **exactly once per key**, so reconciliation never re-runs it for a kept row. To make a row's content reactive, thread a `Stream` **inside** the row instead of expecting a re-render:
|
|
40
52
|
|
|
41
53
|
```typescript
|
|
42
54
|
List.each({ of: Subscribable.changes(rows), by: (row) => row.id }, (row) =>
|
|
@@ -44,7 +56,101 @@ List.each({ of: Subscribable.changes(rows), by: (row) => row.id }, (row) =>
|
|
|
44
56
|
);
|
|
45
57
|
```
|
|
46
58
|
|
|
47
|
-
|
|
59
|
+
## Index-key footgun
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
// Wrong: reuses rows positionally. After a reorder, each position keeps its
|
|
63
|
+
// old content, so the visible rows are stale.
|
|
64
|
+
List.each({ of: Subscribable.changes(rows), by: (_row, i) => i }, renderRow);
|
|
65
|
+
|
|
66
|
+
// Right: a stable identity key follows the item, not its position.
|
|
67
|
+
List.each({ of: Subscribable.changes(rows), by: (row) => row.id }, renderRow);
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Complete example
|
|
71
|
+
|
|
72
|
+
A shuffleable row list. Each row starts a per-row tick counter and renders an uncontrolled `<input>`; shuffling moves rows instead of recreating them, so counters keep counting and typed input keeps its value and focus.
|
|
73
|
+
|
|
74
|
+
```html
|
|
75
|
+
<!-- index.html -->
|
|
76
|
+
<!doctype html>
|
|
77
|
+
<html lang="en">
|
|
78
|
+
<head>
|
|
79
|
+
<meta charset="UTF-8" />
|
|
80
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
81
|
+
<title>Keyed list demo</title>
|
|
82
|
+
</head>
|
|
83
|
+
<body>
|
|
84
|
+
<div id="root"></div>
|
|
85
|
+
<script type="module" src="/src/main.ts"></script>
|
|
86
|
+
</body>
|
|
87
|
+
</html>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
// src/app.ts
|
|
92
|
+
/**
|
|
93
|
+
* Keyed list demo: List.each moves existing rows on shuffle instead of
|
|
94
|
+
* rebuilding them, so each row's own tick counter keeps running and its
|
|
95
|
+
* input keeps focus and value. Side-effect-free (no mount call), so
|
|
96
|
+
* `main.ts` and any test can import `App` directly.
|
|
97
|
+
*/
|
|
98
|
+
import { h, List } from "@weftui/core";
|
|
99
|
+
import { Effect, Schedule, Stream, SubscriptionRef } from "effect";
|
|
100
|
+
|
|
101
|
+
interface Row {
|
|
102
|
+
readonly id: number;
|
|
103
|
+
readonly name: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const renderRow = (row: Row) => {
|
|
107
|
+
// Created once per key: starts a single time and keeps running across
|
|
108
|
+
// every later shuffle of this row.
|
|
109
|
+
const ticks = Stream.iterate(0, (n) => n + 1).pipe(Stream.schedule(Schedule.spaced("1 second")));
|
|
110
|
+
|
|
111
|
+
return h.li({ id: `row-${row.id}` }, [
|
|
112
|
+
h.span(row.name),
|
|
113
|
+
h.input({ placeholder: "type here…" }),
|
|
114
|
+
h.span(["ticks: ", ticks]),
|
|
115
|
+
]);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const App = () =>
|
|
119
|
+
Effect.gen(function* () {
|
|
120
|
+
const rows = yield* SubscriptionRef.make<ReadonlyArray<Row>>([
|
|
121
|
+
{ id: 1, name: "Ada" },
|
|
122
|
+
{ id: 2, name: "Babbage" },
|
|
123
|
+
{ id: 3, name: "Curie" },
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
const shuffle = SubscriptionRef.update(rows, (current) =>
|
|
127
|
+
[...current].sort(() => Math.random() - 0.5),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
return yield* h.div([
|
|
131
|
+
h.button({ onclick: () => shuffle }, "Shuffle"),
|
|
132
|
+
h.ul([List.each({ of: SubscriptionRef.changes(rows), by: (row) => row.id }, renderRow)]),
|
|
133
|
+
]);
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
// src/main.ts
|
|
139
|
+
/**
|
|
140
|
+
* Browser entry: mounts the keyed list demo into #root.
|
|
141
|
+
*/
|
|
142
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
143
|
+
import { Effect } from "effect";
|
|
144
|
+
import { App } from "./app";
|
|
145
|
+
|
|
146
|
+
const root = document.getElementById("root");
|
|
147
|
+
if (root === null) {
|
|
148
|
+
throw new Error("#root not found");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const app = WeftApp.make();
|
|
152
|
+
void Effect.runPromise(WeftApp.mount(app, App(), root));
|
|
153
|
+
```
|
|
48
154
|
|
|
49
155
|
## See also
|
|
50
156
|
|