tutuca 0.9.75 → 0.9.77

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tutuca",
3
- "version": "0.9.75",
3
+ "version": "0.9.77",
4
4
  "type": "module",
5
5
  "description": "Zero-dependency SPA framework with immutable state and virtual DOM",
6
6
  "main": "./dist/tutuca.js",
@@ -29,10 +29,11 @@ When authoring tutuca code, also load these if available:
29
29
  | ---------------------------------------------------------------------------------------------- | ------------------------------- |
30
30
  | Authoring `component({...})`, `html\`...\`` views, macros, fields, events, lists, styles | [core.md](./core.md) |
31
31
  | CLI commands, flags, exit codes, full linter rule list | [cli.md](./cli.md) |
32
+ | `bubble` / `send`-`receive` / async `request`-`response` channels, `$unknown`, request-handler registration | [request-response.md](./request-response.md) |
32
33
  | Drag & drop, dynamic bindings (`*x`), pseudo-`x`, custom seq types, Tailwind/MargaUI | [advanced.md](./advanced.md) |
34
+ | Runtime semantics — path steps, transaction lifecycle, dyn-var teleporting, async key pinning (`livePath`) | [semantics.md](./semantics.md) |
33
35
  | Authoring tests — `getTests` shape, calling methods/input/receive/bubble/response/alter handlers, designing handlers for testability | [testing.md](./testing.md) |
34
36
 
35
- Read `core.md` first. Reach for `cli.md`, `advanced.md`, or
36
- `testing.md` only when the task touches them all three are
37
- referenced inline from `core.md` so you'll be pointed there when
38
- relevant.
37
+ Read `core.md` first. Reach for the others only when the task touches
38
+ them each is referenced inline from `core.md` so you'll be pointed
39
+ there when relevant.
@@ -16,8 +16,10 @@ the `tutuca` CLI.
16
16
  > exit codes, and full linter rule list: see [cli.md](./cli.md).
17
17
  > Authoring tests — `getTests` shape, calling methods/input/receive/
18
18
  > bubble/response/alter handlers, designing for testability: see
19
- > [testing.md](./testing.md). Read those only when the task touches
20
- > them.
19
+ > [testing.md](./testing.md). Runtime semantics path steps, the
20
+ > transaction lifecycle, dyn-var teleporting, and async key pinning
21
+ > (`livePath`): see [semantics.md](./semantics.md). Read those only when
22
+ > the task touches them.
21
23
 
22
24
  ## Verifying changes
23
25
 
@@ -175,9 +177,13 @@ and rebuilds a *positional* `Path` — an array of steps from the root
175
177
  to the value the handler should run against. The same `Path` is reused
176
178
  verbatim for `ctx.send`, `ctx.bubble`, and `ctx.request` /
177
179
  response: because it's positional rather than a captured reference, an
178
- async response still lands at the right slot even after intervening
179
- transactions have rebuilt the root. See
180
- [request-response.md](./request-response.md) for the dispatch APIs.
180
+ async response survives intervening transactions that rebuild the root.
181
+ "The right slot" is exact for named fields and for map entries by key
182
+ (seq-access keys like `.sheets[.selId]` are *pinned* to their
183
+ request-time value by default); a bare list **index** still slides if the
184
+ list reordered. See [request-response.md](./request-response.md) for the
185
+ dispatch APIs and [semantics.md](./semantics.md) for the path/transaction
186
+ model and key pinning.
181
187
 
182
188
  **Why `alter` is its own table.** Alter handlers are pure, evaluated
183
189
  on every render, and produce binds (no state change). `input` /
@@ -924,6 +930,9 @@ export function getTests({ describe, test, expect }) { /*...*/ } // optiona
924
930
  - [advanced.md](./advanced.md) — dynamic bindings (`*x`), pseudo-`@x` for
925
931
  `<select>` / `<table>` / `<tr>`, drag & drop, custom seq types, Tailwind /
926
932
  MargaUI compilation.
933
+ - [semantics.md](./semantics.md) — runtime semantics: path steps, the
934
+ transaction lifecycle, dyn-var teleporting, and async key pinning
935
+ (`livePath`).
927
936
  - [testing.md](./testing.md) — `getTests` shape and the handler calling
928
937
  convention for tests.
929
938
  - [cli.md](./cli.md) — commands, flags, exit codes, and the full linter rule
@@ -0,0 +1,338 @@
1
+ # Tutuca — Request / Response & Orchestration
2
+
3
+ The three event-driven orchestration channels beyond local `input`
4
+ handlers: `bubble` (events up the tree), `send` / `receive` (messages
5
+ to a target path), and async `request` / `response` (host-registered
6
+ async work routed back into component state). Read this file when
7
+ wiring `bubble` / `receive` / `response` handlers, calling
8
+ `ctx.bubble` / `ctx.send` / `ctx.request`, or registering request
9
+ handlers with `registerRequestHandlers`. General authoring lives in
10
+ [core.md](./core.md); testing these handlers is in
11
+ [testing.md](./testing.md).
12
+
13
+ ## The four channels
14
+
15
+ Each channel pairs a trigger with a same-shape handler block:
16
+
17
+ | Triggered by | Handler block |
18
+ | ------------------------------------------- | ------------------- |
19
+ | DOM event (`click`, `input`, …) | `input: { ... }` |
20
+ | `ctx.bubble(name)` — event up the tree | `bubble: { ... }` |
21
+ | `ctx.send(name)` — message to a target path | `receive: { ... }` |
22
+ | `ctx.request(name)` — async request | `response: { ... }` |
23
+
24
+ Every handler is called as `handler(...args, ctx)` and returns a
25
+ (possibly updated) instance of `this`; the framework swaps the returned
26
+ value into the dispatch path. `ctx` (an `EventContext`) is always the
27
+ trailing argument and exposes `ctx.send`, `ctx.bubble`, `ctx.request`,
28
+ `ctx.at` (a `PathBuilder`), `ctx.name` (the dispatched name), and —
29
+ inside bubble handlers — `ctx.stopPropagation()`.
30
+
31
+ `alter` is a fifth handler block, but it isn't event-triggered — the
32
+ renderer invokes alter handlers to produce binds, not to update state.
33
+ See *Mental model* and *Scope Enrichment* in [core.md](./core.md).
34
+
35
+ ## Bubble Events
36
+
37
+ ```js
38
+ input: { onClick(ctx) { ctx.bubble("treeItemSelected", [this]); return this; } },
39
+ bubble: {
40
+ treeItemSelected(selected, ctx) { // ctx.stopPropagation() to halt
41
+ return this.insertInLogAt(0, `selected ${selected.label}`);
42
+ },
43
+ }
44
+ ```
45
+
46
+ `ctx.bubble("name", args)` emits an event that walks the dispatch path
47
+ back toward the root. Each ancestor whose component defines
48
+ `bubble.<name>(...args, ctx)` runs it (others are skipped silently);
49
+ bubbling stops at the root or when a handler calls
50
+ `ctx.stopPropagation()`. Ancestors see the event *after* descendants
51
+ have transacted, so bubble handlers are the place for aggregate state
52
+ (logs, selections, totals).
53
+
54
+ When to bubble: handle the event locally if the current component owns
55
+ the state needed to respond. Bubble when the action belongs to an
56
+ ancestor (a list item's "remove" must reach the list that owns the
57
+ items), or when an ancestor may want to react to or record something
58
+ that happened (selection, logging, analytics). Don't bubble events
59
+ with no consumer.
60
+
61
+ ## Send / Receive
62
+
63
+ `ctx.send(name, args)` delivers a message to a specific target
64
+ component (addressed by path; on its own `ctx.send` targets `this`).
65
+ The target's `receive.<name>(...args, ctx)` handler runs. There is
66
+ **no built-in lifecycle** — `receive.init` is just a convention; the
67
+ host must dispatch it (typically after `app.start()`) for it to run.
68
+
69
+ ```js
70
+ receive: { init(ctx) { ctx.request("loadData"); return this.setIsLoading(true); } }
71
+ ```
72
+
73
+ Dispatch from anywhere:
74
+
75
+ ```js
76
+ app.sendAtRoot("init"); // host code, top-level
77
+ ctx.at.field("personalSite").send("init"); // child by field name
78
+ ctx.at.index("items", 3).send("init"); // list element at index 3
79
+ ctx.at.key("byKey", "k1").send("init"); // map entry by key
80
+ ctx.at.field("a").field("b").index("xs", 0).send("ping"); // chain freely
81
+ ctx.send("name"); // self
82
+ ctx.bubble("name", [arg]); // bubble up
83
+ ```
84
+
85
+ `ctx.at` returns a `PathBuilder` with `.field(name)`, `.index(name, i)`,
86
+ and `.key(name, k)`. Each call appends a step to the path before
87
+ `.send(...)` / `.bubble(...)` fires; the handler runs inside the child
88
+ instance with `this` bound to it. Paths are positional, not references —
89
+ see *Positional delivery* below and *Mental model* in
90
+ [core.md](./core.md) for why this matters across async boundaries.
91
+
92
+ When to send: bubble emits an *event* that any ancestor with a
93
+ matching handler can observe; send delivers a *message* to one
94
+ specific target (or to self). Reach for `ctx.at.…send("name")` when
95
+ one component needs to address another by path — e.g. a form telling
96
+ its email field to focus after a failed submit
97
+ (`ctx.at.field("email").send("focus")`), or a list telling item 3 to
98
+ enter edit mode (`ctx.at.index("items", 3).send("startEditing")`).
99
+ Reach for `ctx.send("name")` on self to reuse a handler from multiple
100
+ call sites without duplicating its body — e.g. a "Reload" button and
101
+ `receive.init` both calling `ctx.send("loadData")`. Don't `send` to
102
+ self when a direct method call on the same component would do.
103
+
104
+ ## Integrating with the outside world
105
+
106
+ A tutuca app talks to the outside world in two directions, and both go
107
+ through handlers — never around them.
108
+
109
+ - **Outbound** — the app reaches out (fetch, timers, IndexedDB, external
110
+ SDKs). Use `ctx.request("name", args)`; the host-registered handler does
111
+ the async work and the result lands back in component state via
112
+ `response.<name>`. See *Async Requests* below.
113
+ - **Inbound** — the outside world pushes an event in (a WebSocket message,
114
+ a DOM event fired outside the app, a `postMessage`, a timer, a
115
+ third-party callback). Use `app.sendAtRoot("name", args)` from the host /
116
+ glue code. It dispatches a `send` to the **root component**, running its
117
+ `receive.<name>(...args, ctx)` handler under the same immutable
118
+ `return this.set…()` contract as every other handler.
119
+
120
+ ```js
121
+ // host / glue code, outside the component tree
122
+ ws.onmessage = (e) => app.sendAtRoot("serverPushed", [JSON.parse(e.data)]);
123
+
124
+ // root component
125
+ receive: {
126
+ serverPushed(msg, ctx) { return this.prependEvent(msg); },
127
+ }
128
+ ```
129
+
130
+ This keeps the root component the single owner of how external inbound
131
+ events mutate state — the logic lives in one `receive` block, in the same
132
+ place and shape as the rest of the app's handlers, and is testable like
133
+ any other (see [testing.md](./testing.md)).
134
+
135
+ ⚠️ **Do not** reach into `app.state` and call the raw `State.set(val)` /
136
+ `State.update(fn)` methods to inject external data. That bypasses the
137
+ component handler model, the immutable `return this.set…()` discipline,
138
+ scope enrichment, and the transactor's batching — state mutated that way is
139
+ invisible to the components that own it and easily clobbered by the next
140
+ transaction. Route every inbound event through `app.sendAtRoot` instead.
141
+
142
+ `sendAtRoot` only targets the root (`Path([])`). To land an inbound event
143
+ on nested state, let the root's `receive` handler forward it with
144
+ `ctx.at.field(...).send(...)` (see *Send / Receive* above) — one entry
145
+ point, still reaching deep. For async/external delivery, anchor on stable
146
+ map keys rather than list indices (see *Positional delivery across async*
147
+ below).
148
+
149
+ ## Async Requests
150
+
151
+ `ctx.request("name", args)` triggers a host-registered async handler
152
+ and routes the result back to the issuing component's
153
+ `response.<name>(res, err, ctx)`. Use it for fetch / timer / IndexedDB
154
+ work that should land back in component state.
155
+
156
+ ```js
157
+ export function getRequestHandlers() {
158
+ return {
159
+ async loadData() {
160
+ const r = await fetch("https://example.com/data.json");
161
+ return await r.json();
162
+ },
163
+ };
164
+ }
165
+
166
+ // register at the same scope where you registerComponents
167
+ const scope = app.registerComponents([Comp]);
168
+ scope.registerRequestHandlers(getRequestHandlers());
169
+ ```
170
+
171
+ In a component:
172
+
173
+ ```js
174
+ receive: { init(ctx) { ctx.request("loadData"); return this.setIsLoading(true); } },
175
+ response: { loadData(res, err, ctx) { return this.setIsLoading(false).setItems(res); } },
176
+ ```
177
+
178
+ ### The `err` argument and the error path
179
+
180
+ The default handler is `response.<name>(res, err, ctx)`. On success the
181
+ host handler's resolved value is `res` and `err` is `null`; on failure
182
+ `res` is `null` and `err` is the thrown / rejected value. Branch on
183
+ `err` when failure needs different state:
184
+
185
+ ```js
186
+ response: {
187
+ loadData(res, err) {
188
+ if (err) return this.setIsLoading(false).setError(String(err));
189
+ return this.setIsLoading(false).setItems(res);
190
+ },
191
+ }
192
+ ```
193
+
194
+ A **request name that isn't registered** doesn't crash — the runtime
195
+ throws `Request not found: <name>` internally and routes it to the same
196
+ error path, so it arrives as `err`. (A `!name` reference written in a
197
+ *template* is caught earlier by the `UNKNOWN_REQUEST_NAME` lint; a
198
+ `ctx.request("name")` string call is not, so a typo there surfaces only
199
+ at runtime as an `err`.)
200
+
201
+ ### Per-call handler-name overrides — and their signature
202
+
203
+ The third argument to `ctx.request` overrides which `response` handler
204
+ runs, with three keys:
205
+
206
+ - `onResName` — base name for **both** outcomes (replaces `<name>`); the
207
+ handler still takes `(res, err, ctx)`.
208
+ - `onOkName` — name for the **success** path only.
209
+ - `onErrorName` — name for the **error** path only.
210
+
211
+ ⚠️ When `onOkName` / `onErrorName` is used, the split handler receives a
212
+ **single** payload arg — *not* `(res, err, ctx)`:
213
+
214
+ ```js
215
+ methods: {
216
+ load(ctx) {
217
+ ctx.request("loadData", [], { onOkName: "loadDataOk", onErrorName: "loadDataErr" });
218
+ return this.setIsLoading(true);
219
+ },
220
+ },
221
+ response: {
222
+ loadDataOk(res, ctx) { return this.setIsLoading(false).setItems(res); }, // res only
223
+ loadDataErr(err, ctx) { return this.setIsLoading(false).setError(String(err)); }, // err only
224
+ },
225
+ ```
226
+
227
+ The combined `(res, err, ctx)` shape is only for the default /
228
+ `onResName` case. Mixing them up — a split handler declaring `(res, err)`
229
+ — means the second param is `ctx`, a common bug. (See
230
+ [testing.md](./testing.md) for how this changes the test call.)
231
+
232
+ ### `livePath` — pinning vs following a moving key
233
+
234
+ The same opts object takes `livePath`. It controls where the response
235
+ lands when the request path addresses a seq-access entry
236
+ (`.sheets[.selId]`): by **default** the resolved key is *pinned* at
237
+ request time, so the response updates the item that issued the request
238
+ even if `.selId` moved while the request was in flight (e.g. the user
239
+ switched tabs). Set `livePath: true` to opt out and re-resolve the key
240
+ live, delivering to whatever the key now points at:
241
+
242
+ ```js
243
+ ctx.request("save", [payload]); // pinned: lands on the originating sheet
244
+ ctx.request("refresh", [], { livePath: true }); // live: lands on the currently selected sheet
245
+ ```
246
+
247
+ Pinning only affects field-resolved keys; named fields are already stable
248
+ and list **indices** are not pinned (a reorder still slides them). Full
249
+ model in [semantics.md](./semantics.md) (*Key resolution & async races*).
250
+
251
+ ### Fire-and-forget requests
252
+
253
+ A request whose result you don't need can omit the `response` handler
254
+ entirely — when no `response.<name>` (and no `$unknown`) matches, the
255
+ result is silently dropped. Idiomatic for side-effect-only work like
256
+ persisting state:
257
+
258
+ ```js
259
+ input: {
260
+ onApplyFilter(value, ctx) {
261
+ ctx.request("persistState", [{ key: "sectionFilter", value }]);
262
+ return this.setFilter(value);
263
+ },
264
+ }
265
+ ```
266
+
267
+ You can also fire several in one handler
268
+ (`ctx.request(...); ctx.request(...); return ...;`).
269
+
270
+ ### The request-handler contract
271
+
272
+ Registered request handlers run with **no `this`** (they're invoked as
273
+ `fn.apply(null, args)`), so they can't read component state — pass
274
+ everything they need through `args`
275
+ (`ctx.request("persistState", [{ key, value }])`). They're plain async
276
+ functions or closures. Aggregate handlers from sub-modules with spread:
277
+
278
+ ```js
279
+ export function getRequestHandlers() {
280
+ return { ...getRequestHandlersA(), ...getRequestHandlersB() };
281
+ }
282
+ ```
283
+
284
+ ### Chaining from a response handler
285
+
286
+ A `response` handler gets the full `ctx`, so it can issue further
287
+ `ctx.request` (request → response → request chains), `ctx.send`, or
288
+ `ctx.bubble`:
289
+
290
+ ```js
291
+ response: {
292
+ loadUser(user, err, ctx) {
293
+ if (!err && user) ctx.request("loadUserDetails", [user.id]);
294
+ return this.setUser(user);
295
+ },
296
+ loadUserDetails(details, err) { return this.setUserDetails(details); },
297
+ }
298
+ ```
299
+
300
+ ## `$unknown` fallback
301
+
302
+ `receive` / `bubble` / `response` all share one fallback: when no
303
+ handler matches the dispatched name, the runtime looks for
304
+ `<block>.$unknown(...args, ctx)` and runs that instead; `ctx.name` tells
305
+ it which name was dispatched. Absent both the named handler and
306
+ `$unknown`, the message is silently dropped (the value passes through
307
+ unchanged). Use `$unknown` for a single catch-all (logging, a generic
308
+ router); rely on the silent drop for fire-and-forget requests.
309
+
310
+ ## Positional delivery across async
311
+
312
+ The path a response (or `send` / `bubble`) is delivered to is
313
+ **positional** — an array of steps from the root, not a captured
314
+ reference. This is why an async response survives intervening
315
+ transactions that rebuilt the root (see *Mental model* in
316
+ [core.md](./core.md)). Per step kind:
317
+
318
+ - **Map entry by key** (`.sheets[.selId]`) — the resolved key is *pinned*
319
+ at request time by default, so the response reaches the originating
320
+ entry even if the key field moved. `livePath: true` opts out (above).
321
+ - **List index** (`.items[3]`) — not pinned: if the list re-sorted so
322
+ index 3 is now a different item, the response lands on whatever occupies
323
+ the slot. Anchor on map keys, not list indices, when an async result
324
+ must reach a specific item.
325
+
326
+ Full model in [semantics.md](./semantics.md).
327
+
328
+ ## See also
329
+
330
+ - [core.md](./core.md) — the core mental model, `view` directives, handler
331
+ blocks overview, and *Conventional Module Exports*.
332
+ - [semantics.md](./semantics.md) — the path/transaction model behind these
333
+ channels: path steps, the transaction lifecycle, teleporting, and the
334
+ key-pinning rules `livePath` toggles.
335
+ - [testing.md](./testing.md) — calling `bubble` / `receive` / `response`
336
+ handlers from tests.
337
+ - [cli.md](./cli.md) — `UNKNOWN_REQUEST_NAME` and the full linter rule list,
338
+ exit codes, and `render` / `test` flags.
@@ -0,0 +1,187 @@
1
+ # Tutuca — Runtime Semantics (paths · transactions · dispatch)
2
+
3
+ How a click becomes a state mutation, and what survives across async.
4
+ Read this when reasoning about **why** a handler ran where it did,
5
+ debugging a dispatch or async-timing bug, or changing `src/path.js` /
6
+ `src/transactor.js`. Not needed for ordinary component authoring — for
7
+ that start at [core.md](./core.md).
8
+
9
+ The step and transaction names below are the ones in the source; confirm
10
+ behavior against `src/path.js` / `src/transactor.js` (or grep the
11
+ `tutuca-source` skill) rather than trusting this doc when they disagree.
12
+
13
+ ## State & identity (in one paragraph)
14
+
15
+ The application is a single immutable root value; the view is a pure
16
+ function of it; every handler takes the old self and returns a new self,
17
+ and the transactor swaps the root atomically. Updating a deep child
18
+ produces a new root that shares structure with the old one along the
19
+ unchanged spine, so the renderer's `===`-keyed cache skips untouched
20
+ subtrees. Full version: *Mental model* in [core.md](./core.md).
21
+
22
+ ## Paths are positional addresses
23
+
24
+ A `Path` is an array of `Step`s from the root to the value a handler runs
25
+ against — a **position**, not a captured reference (see *Paths, not
26
+ references* in [core.md](./core.md)). The step kinds:
27
+
28
+ | Step | Addresses | Source syntax |
29
+ | ------------------- | ---------------------------------- | ------------------------ |
30
+ | `FieldStep` | a named field | `.field` |
31
+ | `SeqStep` | a sequence entry by **literal** key/index | `.items[2]` |
32
+ | `SeqAccessStep` | a sequence entry whose key is **read from another field** | `.sheets[.selId]` |
33
+ | `EachRenderItStep` | an iterated `render-it` item | `<x render-it>` per iter |
34
+ | `DynStep` / `DynEachStep` | a dynamic-var (`*x`) render target — a teleport marker | `<x render="*x">` |
35
+ | `BindStep` / `EachBindStep` | nothing — frame-only (carry scope binds, no addressing) | `@each`, `@enrich-with` |
36
+
37
+ `SeqAccessStep` is the important one for async correctness: it stores the
38
+ field *names* `seqField` and `keyField`, and resolves the key from the
39
+ live data each time it runs — see *Key resolution & async races* below.
40
+
41
+ ### Two derived paths
42
+
43
+ The reconstructed path is transformed two ways depending on use:
44
+
45
+ - **`compact()` → the dispatch path.** Drops frame-only steps, keeps one
46
+ step per crossed component (including `DynStep`s). `popStep()` over it
47
+ bubbles through every component. Used to drive `ctx.send` / `ctx.bubble`
48
+ and to locate handlers.
49
+ - **`toTransactionPath()` → the transaction path.** Teleports every
50
+ `DynStep` (drops the steps interior to its producer..consumer span and
51
+ splices in the producer's own steps) so a mutation lands on the data's
52
+ real location. A path with no `DynStep` is returned unchanged. Used by
53
+ `lookup` / `setValue` to read and write state.
54
+
55
+ ## Reconstructing a path from the DOM
56
+
57
+ The DOM is the only thing that survives between render and click, so the
58
+ renderer leaves breadcrumbs: `data-cid` / `data-nid` / `data-eid` on
59
+ elements, and `§…§` comment "metas" adjacent to component boundaries and
60
+ iteration entries. On an event, `Path.fromNodeAndEventName` walks from the
61
+ target up to the root, reads the breadcrumbs, and rebuilds the path. Along
62
+ the way it resolves the handler: normally on the **leaf** component, but
63
+ for bubbling events (and explicit `bubble`) it can resolve on an
64
+ **ancestor**, in which case the descending steps below that ancestor are
65
+ dropped so the path resolves to the ancestor's value.
66
+
67
+ ## The transaction lifecycle
68
+
69
+ Each dispatch is a `Transaction`. The `Transactor` holds a FIFO queue;
70
+ `App` drains it in time-budgeted batches on a `setTimeout(…, 0)` (see
71
+ `src/app.js`), so transactions complete **asynchronously and interleaved**
72
+ — which is exactly why a request's response can land after other
73
+ transactions have rebuilt the root.
74
+
75
+ The core of applying one is `Transaction.updateRootValue`:
76
+
77
+ ```js
78
+ const txnPath = this.getTransactionPath(); // toTransactionPath(), or a pinned path
79
+ const curLeaf = txnPath.lookup(curRoot); // read the addressed value NOW
80
+ const newLeaf = this.callHandler(curRoot, curLeaf, comps); // old self → new self
81
+ return curLeaf !== newLeaf ? txnPath.setValue(curRoot, newLeaf) : curRoot;
82
+ ```
83
+
84
+ The root swap is atomic and identity-cheap: unchanged subtrees keep their
85
+ references, so re-render is incremental. Cross-transaction ordering and
86
+ fan-out completion are tracked by `Task` (a transaction's `task` resolves
87
+ once its dependency tasks do).
88
+
89
+ ## Dispatch channels, semantically
90
+
91
+ The authoring API (`ctx.send` / `bubble` / `request`, the handler blocks)
92
+ is in [request-response.md](./request-response.md). Underneath, each maps
93
+ to a `Transaction` subclass:
94
+
95
+ | Channel | Transaction | Notes |
96
+ | ------------------- | ---------------- | ------------------------------------------------ |
97
+ | DOM event → `input` | `InputEvent` | transacted **synchronously** (`transactInputNow`), not queued |
98
+ | `ctx.send` → `receive` | `SendEvent` | queued; `skipSelf` runs no self-handler |
99
+ | `ctx.bubble` → `bubble` | `BubbleEvent` | queued; re-pushes itself at `path.popStep()` until it reaches the root or `stopPropagation` |
100
+ | `ctx.request` → `response` | `ResponseEvent` | queued **after** the async work resolves |
101
+
102
+ Bubbling is just walking up the dispatch path one `popStep` at a time.
103
+ `targetPath` (the originator's path) stays fixed as `path` shortens, so a
104
+ bubble handler can reply to the originator via `ctx.sendAtPath(ctx.targetPath, …)`.
105
+
106
+ ## Dynamic-var teleporting
107
+
108
+ A component rendered through `<x render="*sel">` *physically lives* at the
109
+ producer that declared `dynamic: { sel: … }`, not under the consumer that
110
+ wrote the render. The reconstructed dispatch path keeps every intermediate
111
+ component (so bubbling visits them), but `toTransactionPath()` teleports
112
+ the `DynStep`: it pops the steps tagged with the marker's `interiorCids`
113
+ and splices in the producer's own steps (`DynStep.teleportSteps()`). The
114
+ mutation therefore lands on the producer's data, and the consumer's view
115
+ of it updates in lock-step. Authoring view: *Teleporting* in
116
+ [advanced.md](./advanced.md).
117
+
118
+ When the producer's `dynamic` value is a seq-access (`.sheets[.selId]`),
119
+ the teleported steps include a `SeqAccessStep` — which is where async key
120
+ races come from.
121
+
122
+ ## Key resolution & async races
123
+
124
+ A `SeqAccessStep` resolves `keyField` from the live root **every time it
125
+ runs**. For synchronous dispatch this is invisible — the key cannot change
126
+ mid-transaction. For an async `request`/`response` it is the whole
127
+ problem: between issuing the request and applying the response, the key
128
+ may move (e.g. the user switches the selected tab, so `.selId` changes),
129
+ and a naive re-resolution would deliver the response to **whatever item is
130
+ selected now**, not the one that issued the request.
131
+
132
+ **Key pinning is the default.** `pushRequest` snapshots the resolved key
133
+ at request time by running `Path.pinKeys(curRoot)` over the transaction
134
+ path — each `SeqAccessStep(seq, keyField)` becomes a literal
135
+ `SeqStep(seq, resolvedKey)`. The pinned path is stored on the
136
+ `ResponseEvent`, so the response updates the item that issued the request
137
+ regardless of later key changes. (Pinning runs on the transaction path,
138
+ after teleporting, because the `SeqAccessStep` may have come from a
139
+ `DynStep`.)
140
+
141
+ **Opt out per request with `livePath: true`:**
142
+
143
+ ```js
144
+ ctx.request("save", [payload], { livePath: true }); // re-resolve the key live
145
+ ```
146
+
147
+ With `livePath`, the response re-evaluates the key at apply time — the old
148
+ "follow the latest selection" behavior. Use it only when the response is
149
+ *meant* to follow wherever the key now points.
150
+
151
+ Edge cases:
152
+
153
+ - **Pinned target deleted before the response arrives** — the pinned
154
+ `SeqStep` resolves to nothing, the handler runs against a null leaf, and
155
+ the result equals the input → a safe no-op (root unchanged). With
156
+ `livePath` it would instead hit the current item.
157
+ - **The `EventContext` path stays live (un-pinned).** A response handler
158
+ that itself re-dispatches via `ctx.send` / `ctx.request` re-resolves
159
+ against current state — pinning covers the *update*, not nested
160
+ re-dispatch.
161
+
162
+ ## What "positional delivery" guarantees
163
+
164
+ Because a path is a position, an async response survives intervening
165
+ transactions that rebuild the root — but "the right slot" means different
166
+ things per step kind:
167
+
168
+ - **`SeqAccessStep` (`.seq[.key]`)** — the key is **pinned by default**, so
169
+ the response reaches the entry that issued the request even if the key
170
+ field moved. Opt out with `livePath: true`.
171
+ - **`SeqStep` with a list index (`.items[3]`)** — the index is literal and
172
+ **not** pinned to identity: if the list re-sorted or an item was inserted
173
+ ahead of it, index 3 is now a different item and the response lands
174
+ there. Anchor on **map keys**, not list indices, when an async result
175
+ must reach a specific item.
176
+ - **`FieldStep`** — a named field is stable; no ambiguity.
177
+
178
+ ## See also
179
+
180
+ - [core.md](./core.md) — *Mental model* and *Paths, not references* (the
181
+ high-level invariants this file expands on), `view` directives, handler
182
+ blocks.
183
+ - [request-response.md](./request-response.md) — the dispatch **API**:
184
+ `bubble` / `send`-`receive` / `request`-`response`, `ctx.at`, `$unknown`,
185
+ request-handler registration, and the `livePath` request option.
186
+ - [advanced.md](./advanced.md) — dynamic bindings (`*x`) and the authoring
187
+ view of teleporting.
@@ -17,6 +17,9 @@ class Step {
17
17
  toAbstractPathStep() {
18
18
  return this;
19
19
  }
20
+ pinKey(_v) {
21
+ return this;
22
+ }
20
23
  }
21
24
 
22
25
  class BindStep extends Step {
@@ -98,6 +101,10 @@ class SeqAccessStep extends Step {
98
101
  const key = root?.get(this.keyField, NONE);
99
102
  return seq === NONE || key === NONE ? root : root.set(this.seqField, seq.set(key, v));
100
103
  }
104
+ pinKey(v) {
105
+ const key = v?.get(this.keyField, NONE);
106
+ return key === NONE ? this : new SeqStep(this.seqField, key);
107
+ }
101
108
  }
102
109
 
103
110
  class EachBindStep extends Step {
@@ -220,6 +227,20 @@ class Path {
220
227
  }
221
228
  return new Path(out);
222
229
  }
230
+ pinKeys(root) {
231
+ let curVal = root;
232
+ let out = null;
233
+ for (let i = 0;i < this.steps.length; i++) {
234
+ const step = this.steps[i];
235
+ const pinned = step.pinKey(curVal);
236
+ if (pinned !== step)
237
+ (out ??= this.steps.slice())[i] = pinned;
238
+ curVal = step.lookup(curVal, NONE);
239
+ if (curVal === NONE)
240
+ break;
241
+ }
242
+ return out ? new Path(out) : this;
243
+ }
223
244
  lookup(v, dval = null) {
224
245
  let curVal = v;
225
246
  for (const step of this.steps) {
@@ -2664,12 +2685,14 @@ class Transactor {
2664
2685
  }
2665
2686
  async pushRequest(path, name, args = [], opts = {}, parent = null) {
2666
2687
  const curRoot = this.state.val;
2667
- const curLeaf = path.toTransactionPath().lookup(curRoot);
2688
+ const txnPath = path.toTransactionPath();
2689
+ const curLeaf = txnPath.lookup(curRoot);
2668
2690
  const handler = this.comps.getRequestFor(curLeaf, name) ?? mkReq404(name);
2669
2691
  const resHandlerName = opts?.onResName ?? name;
2692
+ const resPath = opts?.livePath ? null : txnPath.pinKeys(curRoot);
2670
2693
  const push = (specificName, baseName, singleArg, result, error) => {
2671
2694
  const resArgs = specificName ? [singleArg] : [result, error];
2672
- const t = new ResponseEvent(path, this, specificName ?? baseName, resArgs, parent);
2695
+ const t = new ResponseEvent(path, this, specificName ?? baseName, resArgs, parent, resPath);
2673
2696
  this.pushTransaction(t);
2674
2697
  };
2675
2698
  try {
@@ -2744,8 +2767,11 @@ class Transaction {
2744
2767
  getHandlerAndArgs(_root, _instance, _comps) {
2745
2768
  return null;
2746
2769
  }
2770
+ getTransactionPath() {
2771
+ return this.path.toTransactionPath();
2772
+ }
2747
2773
  updateRootValue(curRoot, comps) {
2748
- const txnPath = this.path.toTransactionPath();
2774
+ const txnPath = this.getTransactionPath();
2749
2775
  const curLeaf = txnPath.lookup(curRoot);
2750
2776
  const newLeaf = this.callHandler(curRoot, curLeaf, comps);
2751
2777
  this._task?.complete?.({ value: newLeaf, old: curLeaf });
@@ -2854,6 +2880,13 @@ class NameArgsTransaction extends Transaction {
2854
2880
 
2855
2881
  class ResponseEvent extends NameArgsTransaction {
2856
2882
  handlerProp = "response";
2883
+ constructor(path, transactor, name, args, parent, txnPath = null) {
2884
+ super(path, transactor, name, args, parent);
2885
+ this._txnPath = txnPath;
2886
+ }
2887
+ getTransactionPath() {
2888
+ return this._txnPath ?? super.getTransactionPath();
2889
+ }
2857
2890
  }
2858
2891
 
2859
2892
  class SendEvent extends NameArgsTransaction {