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/dist/tutuca-cli.js +36 -3
- package/dist/tutuca-dev.ext.js +36 -3
- package/dist/tutuca-dev.js +36 -3
- package/dist/tutuca-dev.min.js +2 -2
- package/dist/tutuca-extra.ext.js +36 -3
- package/dist/tutuca-extra.js +36 -3
- package/dist/tutuca-extra.min.js +2 -2
- package/dist/tutuca.ext.js +36 -3
- package/dist/tutuca.js +36 -3
- package/dist/tutuca.min.js +2 -2
- package/package.json +1 -1
- package/skill/tutuca/SKILL.md +5 -4
- package/skill/tutuca/core.md +14 -5
- package/skill/tutuca/request-response.md +338 -0
- package/skill/tutuca/semantics.md +187 -0
- package/skill/tutuca-source/tutuca.ext.js +36 -3
package/package.json
CHANGED
package/skill/tutuca/SKILL.md
CHANGED
|
@@ -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
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
package/skill/tutuca/core.md
CHANGED
|
@@ -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).
|
|
20
|
-
>
|
|
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
|
|
179
|
-
|
|
180
|
-
[
|
|
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
|
|
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.
|
|
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 {
|