@weftui/router 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,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. The rpc's `_tag` is the boundary's stable identity and its payload schema the typed input.
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
- ## Overview
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. 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:
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. The brand makes a leak into `render` a compile error at the `hydrate` call site rather than a runtime surprise.
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
- `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.
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 (not a defect).
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 (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, 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); it does **not** unmount the subtree or raise into a failure `Boundary`.
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**. 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.
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
- 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.
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
- 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).
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(GetStock, () => ({ id: product.id }), (resource) => /* … */),
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`. This is the common case and needs nothing else. The layer builds lazily on first mount, and every component, event handler, and stream subscription in every root mounted from `app` can read it.
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 whose items reorder, insert, or remove over time, without rebuilding the whole region (which would lose focus, scroll, and input state in the surviving rows).
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
- - **`of`** is the list source: any `Stream`, `Effect`, or `Subscribable` of an `Iterable`. Each emission is materialized to an array to fix order, then reconciled by key.
29
- - **`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).
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
- Mapping items by hand (`Stream.map(Subscribable.changes(rows), (rs) => rs.map(r => h.li(r.name)))`) produces a **new children array on every emission**. The renderer then rebuilds the whole region: every row's DOM node is recreated even if only one item moved.
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
- Because `render` runs **exactly once per key**, reconciliation never re-runs it for a kept row, so it never refreshes that row's content on its own. To make a row's content reactive, thread a `Stream` **inside** the row rather than expecting a re-render:
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
- > **⚠️ Index-key footgun.** Keying by index (`by: (_, i) => i`) reuses rows positionally, so after a reorder each position keeps its old content and you see stale rows. Prefer a stable identity key (`by: (item) => item.id`).
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