@wcstack/state 2.1.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +283 -6
- package/README.md +284 -6
- package/dist/auto.min.js +1 -1
- package/dist/auto.min.js.map +1 -1
- package/dist/index.d.ts +295 -2
- package/dist/index.esm.js +2259 -179
- package/dist/index.esm.js.map +1 -1
- package/dist/manifest.esm.js +4 -0
- package/dist/parser.esm.js +21 -4
- package/dist/wcs-manifest.json +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -114,10 +114,11 @@ That's it. No build, no bootstrap code, no framework.
|
|
|
114
114
|
- **Event tokens** — the dual of command tokens: receive a wc-bindable element's dispatched events in state via `eventToken.<prop>: tokenName` + the `$on` map
|
|
115
115
|
- **Streams** — fold continuous async flows (async iterables / `ReadableStream`) into reactive properties via the `$streams` declaration, with switchMap-style dependency-driven restart
|
|
116
116
|
- **Path getters** — dot-path key getters (`get "users.*.fullName"()`) for virtual properties at any depth in a data tree, all defined flat in one place with automatic dependency tracking and caching
|
|
117
|
+
- **Recursive paths** — `$recursion: { "nodes.*": "children.*" }` declares where a tree's shape repeats, and one `**` getter (`get "nodes.**.total"()`) covers every depth; `$getAll(path, [])` unions all depths and `$setAll(path, [], value)` broadcasts to all of them
|
|
117
118
|
- **Mustache syntax** — `{{ path|filter }}` in text nodes
|
|
118
119
|
- **Multiple state sources** — JSON, JS module, inline script, API, attribute
|
|
119
120
|
- **SVG support** — full binding support inside `<svg>` elements
|
|
120
|
-
- **Lifecycle hooks** — `$connectedCallback` / `$disconnectedCallback` / `$updatedCallback`, plus `$stateReadyCallback` for Web Components
|
|
121
|
+
- **Lifecycle hooks** — `$connectedCallback` / `$disconnectedCallback` / `$updatedCallback` / `$errorCallback`, plus `$stateReadyCallback` for Web Components
|
|
121
122
|
- **Headless watch** — `$watch` fires on state changes whether or not the path is rendered
|
|
122
123
|
- **Diagnostics** — unresolved paths, index arity and getter cycles are reported with the same codes as `@wcstack/lint` and the VS Code extension
|
|
123
124
|
- **TypeScript support** — `defineState()` for typed state definitions with dot-path autocompletion ([details](docs/define-state.md)); `@wcstack/typescript` carries the same types into the HTML validator (`wcs-schema`) and type-checks inline state scripts (`wcs-tsc`) — see [docs/typescript.md](../../docs/typescript.md)
|
|
@@ -247,7 +248,7 @@ There is **one state tree per root**. To split state across modules, mount a vol
|
|
|
247
248
|
<div data-wcs="textContent: cart.total"></div>
|
|
248
249
|
```
|
|
249
250
|
|
|
250
|
-
A volume may declare getters, `$watch`, `$listKeys`, `$updatedCallback`, and `$connectedCallback`/`$disconnectedCallback` — all relative to its mount path. Load order does not matter (a volume connected before the root is grafted when the root registers). Mount paths must be static (`*`, `$`, `#`, `@` are rejected). Changing `mount` after the element has initialized is not supported: the change is ignored with a console warning — remove the element and add a new one with the desired path.
|
|
251
|
+
A volume may declare getters, `$watch`, `$listKeys`, `$updatedCallback`, and `$connectedCallback`/`$disconnectedCallback` — all relative to its mount path. `$errorCallback` is root-only (a binding failure is reported once, to the tree's owner). Load order does not matter (a volume connected before the root is grafted when the root registers). Mount paths must be static (`*`, `$`, `#`, `@` are rejected). Changing `mount` after the element has initialized is not supported: the change is ignored with a console warning — remove the element and add a new one with the desired path.
|
|
251
252
|
|
|
252
253
|
> **Migrating from v1's named states:** `<wcs-state name="cart">` + `total@cart` becomes `<wcs-state mount="cart">` + `cart.total`. In v2 the `name` attribute fails fast and `@` in a path is a parse error, each with this exact guidance. Migration table: [docs/state-mount-design.md](../../docs/state-mount-design.md) §9.
|
|
253
254
|
|
|
@@ -362,6 +363,8 @@ Automatically enabled for:
|
|
|
362
363
|
|
|
363
364
|
### Binding Authority (`#init=` / `#sync=`)
|
|
364
365
|
|
|
366
|
+
**The problem this solves.** An element that already holds a value when its binding attaches — `<wcs-storage>` after loading a persisted value, a clock, a widget restoring its own snapshot — is overwritten by the state seed, because the initial sync of a two-way binding writes state→element. Adding `#init=element` to that one binding makes the *element* win the initial sync instead; later changes flow both ways as usual. That case (load-before-bind) is spelled out below; the rest of this section is the general rule it is an instance of.
|
|
367
|
+
|
|
365
368
|
For custom elements that declare `static wcBindable`, every prop binding resolves an **authority** — which side wins the **initial sync** when the binding attaches. The steady-state direction is decided separately, by the member's declared shape: an output-only member never accepts state writes (a permanent contract), while a two-way member flows both ways after the initial sync regardless of which side won it. The default authority is derived from where the member is declared (on by default via `enableDirectionalInitialSync`):
|
|
366
369
|
|
|
367
370
|
| Member declared in | Default authority | Effect |
|
|
@@ -377,7 +380,7 @@ For custom elements that declare `static wcBindable`, every prop binding resolve
|
|
|
377
380
|
|
|
378
381
|
When the element dispatches `properties[].event`, the value written to state is **`getter(event)`**. With no `getter`, the protocol default applies — [`(e) => e.detail`](https://github.com/wc-bindable-protocol/wc-bindable-protocol/blob/main/SPEC.md#default-getter): the **whole `detail`, as-is**. The declared property is *not* read off the element at that point; the event payload is authoritative. A plain HTML element (no `wcBindable`) is the other way round: `element[propName]` is read on `input`/`change`.
|
|
379
382
|
|
|
380
|
-
So an element that dispatches `detail: { value: 7654321 }` without a `getter` writes the **object** `{ value: 7654321 }` to state, not the number — and the failure is silent: the write-back (`Number({ value: … })` → `NaN`)
|
|
383
|
+
So an element that dispatches `detail: { value: 7654321 }` without a `getter` writes the **object** `{ value: 7654321 }` to state, not the number — and the failure is mostly silent: the write-back (`Number({ value: … })` → `NaN`) throws nothing, and `@wcstack/lint` cannot see it (the payload shape is not static). The runtime warns once per element and property (`wcs/default-getter-mismatch`) for the two shapes it can tell apart at the event: a `detail` that is `undefined` while the element property has a value (a plain `Event`, or a forgotten `detail`), and a `detail` object carrying a `<propName>` key while the property is not an object (the wrapper above). Any other mismatch goes through unnoticed, and the write is applied as-is either way. Use one of the two conforming shapes:
|
|
381
384
|
|
|
382
385
|
```javascript
|
|
383
386
|
class YenInput extends HTMLElement {
|
|
@@ -944,6 +947,20 @@ export default {
|
|
|
944
947
|
|
|
945
948
|
Getters that throw are not swallowed: the exception surfaces where the getter was evaluated (a binding apply, a `$watch` evaluation, or your own read).
|
|
946
949
|
|
|
950
|
+
#### Dependency tracking boundaries
|
|
951
|
+
|
|
952
|
+
Three rules decide what the dependency graph sees. None of them matters until you cross one, and when you do the symptom is a value that stops updating with no error — so they are collected here:
|
|
953
|
+
|
|
954
|
+
| Rule | What it looks like when crossed |
|
|
955
|
+
|---|---|
|
|
956
|
+
| **Only path reads through `this` are tracked.** `this.form` tracks `form`; `this["form.name"]` tracks `form.name`; `this.form.name` tracks **`form` only** — the `.name` is a plain property access on the object that came back. `Date.now()`, the DOM, a module variable, a closed-over object register nothing | The getter is never re-evaluated for that input; the first value sticks (the examples above). A getter that reads `this.form.name` does not re-run when a bound `<input data-wcs="value: form.name">` changes — read `this["form.name"]` |
|
|
957
|
+
| **Reads inside a setter are not tracked.** A setter is an imperative assignment, not a derivation, so nothing it reads becomes a dependency of anything | A setter that reads `this.a` to decide what to write does not run again when `a` changes — only a getter re-runs |
|
|
958
|
+
| **The same-value guard applies to primitives only.** A primitive write `Object.is`-equal to the current value is dropped before anything is enqueued; an object or array write always passes, even the same reference | Assigning the same string again fires nothing; assigning the same object again re-fires its bindings and `$watch` (`config.sameValueGuard`; a `semantics: "event"` property is exempt either way) |
|
|
959
|
+
|
|
960
|
+
The first rule is the one static analysis can catch: `wcs-validate` and the VS Code extension report `wcs/getter-untracked-read` when a getter reads `this.form.name` and the document writes `form.name` somewhere (a `value:` binding, a spread, `this["form.name"] = …`). A root that is only ever replaced wholesale — router params, a `$streams` fold — is left alone.
|
|
961
|
+
|
|
962
|
+
`$untrackDependency(fn)` applies the setter rule to a getter on purpose: reads inside `fn` are not tracked. `$trackDependency(path)` is the escape hatch for the first rule.
|
|
963
|
+
|
|
947
964
|
### Loop Index Variables (`$1`, `$2`, ...)
|
|
948
965
|
|
|
949
966
|
Inside getters and event handlers, `this.$1`, `this.$2`, etc. provide the current loop iteration index (0-based value, 1-based naming):
|
|
@@ -1087,6 +1104,207 @@ export default {
|
|
|
1087
1104
|
};
|
|
1088
1105
|
```
|
|
1089
1106
|
|
|
1107
|
+
## Recursive Paths (`$recursion`)
|
|
1108
|
+
|
|
1109
|
+
A path burns its depth into the string. `nodes.*.children.*.total` has exactly two wildcard levels, and nothing about it stretches to three when the tree grows a level — but a tree's depth belongs to the data, not to the code. `$recursion` closes that gap: declare where the shape repeats, then write `**` for "however deep this is".
|
|
1110
|
+
|
|
1111
|
+
```javascript
|
|
1112
|
+
export default {
|
|
1113
|
+
$recursion: { "nodes.*": "children.*" }, // anchor → repeating sub-path
|
|
1114
|
+
|
|
1115
|
+
nodes: [
|
|
1116
|
+
{ value: 1, selected: false, children: [
|
|
1117
|
+
{ value: 10, selected: false, children: [
|
|
1118
|
+
{ value: 100, selected: false, children: [] }
|
|
1119
|
+
]},
|
|
1120
|
+
{ value: 20, selected: false, children: [] }
|
|
1121
|
+
]},
|
|
1122
|
+
{ value: 2, selected: false, children: [] }
|
|
1123
|
+
],
|
|
1124
|
+
|
|
1125
|
+
// One getter, every depth: `**` is bound to the depth being evaluated
|
|
1126
|
+
get "nodes.**.total"() {
|
|
1127
|
+
return this["nodes.**.value"]
|
|
1128
|
+
+ this.$getAll("nodes.**.children.*.total").reduce((a, b) => a + b, 0);
|
|
1129
|
+
},
|
|
1130
|
+
|
|
1131
|
+
// Whole-tree aggregate: `[]` unions every depth
|
|
1132
|
+
get treeTotal() {
|
|
1133
|
+
return this.$getAll("nodes.**.value", []).reduce((a, b) => a + b, 0);
|
|
1134
|
+
},
|
|
1135
|
+
|
|
1136
|
+
clearSelection() {
|
|
1137
|
+
this.$setAll("nodes.**.selected", [], false);
|
|
1138
|
+
}
|
|
1139
|
+
};
|
|
1140
|
+
```
|
|
1141
|
+
|
|
1142
|
+
For that forest the totals are `131 / 110 / 100 / 20 / 2` and `treeTotal` is `133`.
|
|
1143
|
+
|
|
1144
|
+
**`**` is authoring notation only — it never reaches the engine.** Reading a concrete path (`nodes.*.children.*.total`) materializes the getter for *that* depth on demand, one accessor per depth you actually touch, and everything downstream — `PathInfo`, the dependency graph, `$1`…`$n`, `$resolve`, the list diff — still sees an ordinary fixed-arity path. The reactive core did not learn a new shape.
|
|
1145
|
+
|
|
1146
|
+
### Declaring the recursion point
|
|
1147
|
+
|
|
1148
|
+
`$recursion` maps one **anchor** to the **repeating sub-path** that descends one level. Both name the *element* of a list — a fixed property chain ending in `.*`, never the list itself:
|
|
1149
|
+
|
|
1150
|
+
```javascript
|
|
1151
|
+
$recursion: { "nodes.*": "children.*" } // nodes[i].children[j].children[k]…
|
|
1152
|
+
$recursion: { "data.tree.*": "kids.*" } // a deeper anchor is fine
|
|
1153
|
+
$recursion: { "nodes.*": "nodes.*" } // self-similar spelling is fine too
|
|
1154
|
+
```
|
|
1155
|
+
|
|
1156
|
+
The declaration is what gives `**` a meaning at all: with no `$recursion` on the state, `**` is not a path character (`wcs/recursion-unsupported`), so the notation can never quietly slide into a descendant search. This version accepts **exactly one self-recursive anchor per state**. A wildcard in the middle of an anchor, a second entry, mutual recursion between two anchors, a second `**` in one path, `get "nodes.**"` (that names the node itself, not a computed path under it), a `**` getter whose suffix names the structure (`get "nodes.**.children"()`, `.children.*`, `.children.length` — it would hide the real child list at every depth), two `**` getters that expand to the same concrete path, and recursive *setters* are all rejected when the declaration is read — never reinterpreted.
|
|
1157
|
+
|
|
1158
|
+
The family a declaration defines is infinite, and the state only ever grows the depths it is asked for:
|
|
1159
|
+
|
|
1160
|
+
```
|
|
1161
|
+
k=0 nodes.*
|
|
1162
|
+
k=1 nodes.*.children.*
|
|
1163
|
+
k=2 nodes.*.children.*.children.*
|
|
1164
|
+
```
|
|
1165
|
+
|
|
1166
|
+
### What `**` means where
|
|
1167
|
+
|
|
1168
|
+
`**` is a variable over depth, and whether it is *bound* or *unioned* is decided by context — the same split `*` already has between "the current row" and "every row":
|
|
1169
|
+
|
|
1170
|
+
| Where `**` appears | What it means |
|
|
1171
|
+
|---|---|
|
|
1172
|
+
| A getter key — `get "nodes.**.total"()` | Bound to the depth being evaluated |
|
|
1173
|
+
| A path read inside that getter — `this["nodes.**.value"]` | Bound to the same depth |
|
|
1174
|
+
| `$getAll(path)`, indexes **omitted** | Bound to that depth; only the wildcards *after* `**` expand |
|
|
1175
|
+
| `$getAll(path, [])`, **explicit** | **Union of every depth** — depth-first, pre-order, ascending index |
|
|
1176
|
+
| `$getAll(path, [i, …])` | Rejected: a prefix cannot say which depth it applies to (`wcs/recursion-getall-form`) |
|
|
1177
|
+
| `$setAll(path, [], value)` | Broadcast to every depth, in that same order |
|
|
1178
|
+
| `$resolve`, `$postUpdate`, `$trackDependency`, `$watch` keys, `$listKeys` keys, `data-wcs` in markup, direct assignment | Rejected (`wcs/recursion-unsupported`) |
|
|
1179
|
+
|
|
1180
|
+
The bound forms need a depth to bind to, so they only resolve **inside** a recursive getter — or inside an ordinary row getter under the anchor, or an event handler bound to such a row — each of those carries a real `ListIndex` to read the depth from. Read `this["nodes.**.value"]` from the top level and you get `wcs/recursion-context`, not a silent guess at which node you meant. The depth is read from the innermost evaluation frame only, the same frame the row index comes from: a plain getter that a recursive getter calls (`get "nodes.**.x"() { return this.helper }` with `get helper() { return this["nodes.**.value"] }`) has no row of its own and gets `wcs/recursion-context` too — read `**` in the recursive getter and pass the value on. The union form needs no depth, so it can be read from anywhere: a top-level getter, a plain row getter, a method.
|
|
1181
|
+
|
|
1182
|
+
```javascript
|
|
1183
|
+
this.$getAll("nodes.**.value", []); // [1, 10, 100, 20, 2] — depth-first, pre-order
|
|
1184
|
+
```
|
|
1185
|
+
|
|
1186
|
+
Wildcards *after* `**` expand at each node in the ordinary fixed-arity order, and the walk finishes them before descending to that node's children. With `tags` on the nodes above (`1` → `[3, 4]`, `10` → `[5]`, `20` → `[7]`, the rest empty):
|
|
1187
|
+
|
|
1188
|
+
```javascript
|
|
1189
|
+
this.$getAll("nodes.**.tags.*.v", []); // [3, 4, 5, 7] — node 1's tags, then node 10's, then node 20's
|
|
1190
|
+
```
|
|
1191
|
+
|
|
1192
|
+
### Aggregating without counting grandchildren twice
|
|
1193
|
+
|
|
1194
|
+
That split is the whole game for aggregation, because the recursive getter is what folds the tree:
|
|
1195
|
+
|
|
1196
|
+
```javascript
|
|
1197
|
+
// ✅ Omitted — bound to this depth, so the sum walks only the direct children
|
|
1198
|
+
get "nodes.**.total"() {
|
|
1199
|
+
return this["nodes.**.value"]
|
|
1200
|
+
+ this.$getAll("nodes.**.children.*.total").reduce((a, b) => a + b, 0);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// ❌ `[]` — every child total at every depth. Each node's total would contain its
|
|
1204
|
+
// own descendants' totals again, and the getter ends up asking for itself: in
|
|
1205
|
+
// practice you do not get a wrong number, you get `wcs/getter-cycle`.
|
|
1206
|
+
get "nodes.**.total"() {
|
|
1207
|
+
return this["nodes.**.value"]
|
|
1208
|
+
+ this.$getAll("nodes.**.children.*.total", []).reduce((a, b) => a + b, 0);
|
|
1209
|
+
}
|
|
1210
|
+
```
|
|
1211
|
+
|
|
1212
|
+
The same mistake made from *outside* the recursion is the quiet one — there is no cycle to trip over, just a plausible number that is too big. A union of an aggregate counts every grandchild once inside its parent's total, and once more as an element of the union:
|
|
1213
|
+
|
|
1214
|
+
```javascript
|
|
1215
|
+
// ❌ 363 — every node's total, and every total already contains its subtree
|
|
1216
|
+
get treeTotalWrong() {
|
|
1217
|
+
return this.$getAll("nodes.**.total", []).reduce((a, b) => a + b, 0);
|
|
1218
|
+
}
|
|
1219
|
+
// ✅ 133 — union the raw leaf values
|
|
1220
|
+
get treeTotal() {
|
|
1221
|
+
return this.$getAll("nodes.**.value", []).reduce((a, b) => a + b, 0);
|
|
1222
|
+
}
|
|
1223
|
+
// ✅ 133 — or add up the roots, since each root total already folds its subtree
|
|
1224
|
+
get treeTotalFromRoots() {
|
|
1225
|
+
return this.$getAll("nodes.*.total", []).reduce((a, b) => a + b, 0);
|
|
1226
|
+
}
|
|
1227
|
+
```
|
|
1228
|
+
|
|
1229
|
+
**Union raw values, or sum the roots — never union something that already aggregates its own subtree.** Whether an aggregate double-counts is not decidable from the path string, so no diagnostic claims to catch this one.
|
|
1230
|
+
|
|
1231
|
+
### Writing: broadcast only
|
|
1232
|
+
|
|
1233
|
+
`$setAll` accepts `**` in exactly one form — `[]` plus a plain value — and returns the number of addresses written (5 for the forest above):
|
|
1234
|
+
|
|
1235
|
+
```javascript
|
|
1236
|
+
this.$setAll("nodes.**.selected", [], false); // every node, at every depth
|
|
1237
|
+
```
|
|
1238
|
+
|
|
1239
|
+
Every other form is refused *before* the walk writes anything, so a rejected call leaves the tree untouched. That guarantee covers the checks on the *form*; a leaf under an index spelling (`nodes.**.children.0.value`) can still stop part-way on the *data* — a node whose `children` is empty has no child `0` to write into — exactly as the fixed-arity `$setAll("nodes.*.children.0.value", [], v)` does.
|
|
1240
|
+
|
|
1241
|
+
| Form | Why it is refused |
|
|
1242
|
+
|---|---|
|
|
1243
|
+
| a non-empty prefix | A prefix cannot say which depth it applies to (`wcs/recursion-setall-form`) |
|
|
1244
|
+
| omitted indexes | The write API takes no context, so there is no depth to bind to — pass `[]` |
|
|
1245
|
+
| a mapper function | `(current, ...indexes)` has a different arity at every depth |
|
|
1246
|
+
| `{ spread: true }` | Handing a flat array to a tree needs the author to know the walk order |
|
|
1247
|
+
| `nodes.**`, `nodes.**.children`, `nodes.**.children.*`, `nodes.**.children.length` — and, for a multi-segment repeat such as `branch.children.*`, the `nodes.**.branch` on the way to the list. Index spellings fold to the same forms: `nodes.**.children.0` is a child node, `nodes.**.children.0.total` is the getter | Writing the structure itself (assigning `length` truncates the list) invalidates the child addresses this very write already resolved (`wcs/recursion-structural-write`) |
|
|
1248
|
+
| `nodes.**.total`, or a path inside its value | A recursive getter has no setter — write what it derives from (`wcs/recursion-readonly`) |
|
|
1249
|
+
|
|
1250
|
+
The read-only rule does not depend on spelling `**`. A recursive getter's concrete expansions — `nodes.*.total`, `nodes.*.children.*.total`, … — are refused at the write entry as well, whether the write is a fixed-arity `$setAll`, a `$resolve(path, indexes, value)` or a direct assignment, and whether or not that depth has been materialized yet. Before this check, an unmaterialized expansion looked like a plain missing key and the write landed on the node object, pinning the assigned value as the getter's cached result.
|
|
1251
|
+
|
|
1252
|
+
### The input has to be a tree
|
|
1253
|
+
|
|
1254
|
+
The walk descends by depth and checks the shape it needs as it goes: reaching the **same array instance** twice is refused. If that array belongs to one of the current node's ancestors it is a cycle (`wcs/recursion-cycle`); otherwise two nodes share one child list (`wcs/recursion-shared-list`). Give every node its own `children` array — sharing an *empty* one is fine and untracked, because it has no rows to alias.
|
|
1255
|
+
|
|
1256
|
+
**One shape the walk accepts but the engine cannot follow yet: replacing a row object while keeping its `children` array.** After `this.nodes = this.nodes.map(n => ({ ...n }))` the child list's ledger is still keyed by the array, so its rows stay attached to the *old* row object. Once that row's aggregate has been read, the next leaf update below it leaves that row's `nodes.*.total` stale — the leaf, the deeper totals and every `[]` union are still right, so nothing complains. Write rows in place through paths (`$resolve`, `$setAll`), keep the row objects (`[...this.nodes]`), or replace the whole subtree (a deep clone), and the aggregates follow. This is a limit of list identity, not of `**`: hand-written `nodes.*.total` / `nodes.*.children.*.total` getters behave the same way ([#256](https://github.com/wcstack/wcstack/issues/256)).
|
|
1257
|
+
|
|
1258
|
+
The ceiling is **128 wildcard levels** on the expanded path. The aggregate above reads one level below the node it is evaluating, so it folds a chain 127 deep and stops at 128 with `wcs/recursion-depth-exceeded`, naming the anchor, the depth reached, the path it was building, and the limit. That check trips before the getter stack's own 128-frame limit (`wcs/getter-depth-exceeded`), so a deep tree is reported as deep instead of being accused of a cycle. Nothing is truncated on the way: a partial aggregate would be a wrong number reported as a right one.
|
|
1259
|
+
|
|
1260
|
+
### Rendering the tree
|
|
1261
|
+
|
|
1262
|
+
`**` cannot appear in markup and there is no recursive `<template>`. A tree is rendered by a **self-referential component** — one custom element whose shadow mounts itself for each child. Inside every scope only one level of path is ever used (`node.children.*`), so the markup does not depend on the depth, and `node.total` resolves through the mount onto the root state's recursive getter, so each node shows its own subtree's aggregate.
|
|
1263
|
+
|
|
1264
|
+
```html
|
|
1265
|
+
<!-- host -->
|
|
1266
|
+
<template data-wcs="for: nodes">
|
|
1267
|
+
<tree-node data-wcs="state.node: nodes.*"></tree-node>
|
|
1268
|
+
</template>
|
|
1269
|
+
```
|
|
1270
|
+
|
|
1271
|
+
```javascript
|
|
1272
|
+
const markup = `
|
|
1273
|
+
<wcs-state bind-component="state"></wcs-state>
|
|
1274
|
+
<span data-wcs="textContent: node.label"></span>
|
|
1275
|
+
<span data-wcs="textContent: node.total"></span>
|
|
1276
|
+
<template data-wcs="for: node.children">
|
|
1277
|
+
<tree-node data-wcs="state.node: node.children.*"></tree-node>
|
|
1278
|
+
</template>`;
|
|
1279
|
+
|
|
1280
|
+
customElements.define("tree-node", class extends HTMLElement {
|
|
1281
|
+
state = {}; // ← no own `node` key — it arrives from the mount
|
|
1282
|
+
constructor() { super(); this.attachShadow({ mode: "open" }); }
|
|
1283
|
+
connectedCallback() { // ← build the shadow here, not in the constructor
|
|
1284
|
+
if (this.shadowRoot.childNodes.length === 0) this.shadowRoot.innerHTML = markup;
|
|
1285
|
+
}
|
|
1286
|
+
});
|
|
1287
|
+
```
|
|
1288
|
+
|
|
1289
|
+
Two things bite here, and both were hit for real:
|
|
1290
|
+
|
|
1291
|
+
- **The component's `state` must not declare the key it is mounted over.** Unrelated methods and private keys are fine — a `node` of its own is not: it hides the mount, so the child shows its own default and never descends. The runtime names that one (`wcs/mount-own-key-shadow`).
|
|
1292
|
+
- **Build the shadow in `connectedCallback`, not in the constructor.** Assigning `innerHTML` in the constructor upgrades the elements inside `<template>` on implementations that do not keep template content inert, and a self-referential element then recurses forever in its own constructor. Real browsers survive it, which makes it an environment-dependent trap rather than an honest crash.
|
|
1293
|
+
|
|
1294
|
+
Fixed depths need none of this: the expanded paths are ordinary paths, so nested `for` templates bind `nodes.*.total` and `nodes.*.children.*.total` like anything else.
|
|
1295
|
+
|
|
1296
|
+
### Not in this version
|
|
1297
|
+
|
|
1298
|
+
Each of these is a diagnostic, never a silent reinterpretation:
|
|
1299
|
+
|
|
1300
|
+
- More than one anchor, mutual recursion, a wildcard in the middle of an anchor, a second `**` in one path
|
|
1301
|
+
- Recursive setters, a `**` getter whose suffix names the structure (`get "nodes.**.children"()`), a concrete getter with the same name as a `**` getter's expansion, and writing through `**` by assignment (`this["nodes.**.x"] = v`, `++` included)
|
|
1302
|
+
- In a recursive `$setAll`: a mapper, `{ spread: true }`, omitted indexes, a non-empty prefix, or a non-array `indexes`. The write API has no evaluation context to bind a depth to, so `[]` is mandatory
|
|
1303
|
+
- In a recursive `$getAll`: a non-empty prefix, or a non-array `indexes`. **Omitting the indexes is valid** — inside a recursive getter it is the bound form, and it reads the depth being evaluated
|
|
1304
|
+
- `**` in `data-wcs`, in `$watch` or `$listKeys` keys, or in `$resolve` / `$postUpdate` / `$trackDependency`
|
|
1305
|
+
- `$recursion` and `**` getters in a volume (`mount=`) or a mounted component (`bind-component`) — declare them on the root state
|
|
1306
|
+
- A recursive `<template>`, a `$depth` variable, and a public `maxDepth` option — none of the three exist
|
|
1307
|
+
|
|
1090
1308
|
## Event Handling
|
|
1091
1309
|
|
|
1092
1310
|
Bind event handlers with `on*` properties:
|
|
@@ -1334,6 +1552,23 @@ customElements.define("user-card", UserCard);
|
|
|
1334
1552
|
> (and on `this` inside getters/methods) speak the component's own vocabulary — paths are
|
|
1335
1553
|
> translated onto the mount and the host row's indexes are prepended automatically.
|
|
1336
1554
|
|
|
1555
|
+
#### Exported getters (reading a component's getter from outside)
|
|
1556
|
+
|
|
1557
|
+
A mounted component's getters are **exported** at the mount point: **a read of a key the tree does not have is answered by the getter of the component mounted there. A key the tree does have wins. Private keys and methods are never visible.** With the `user-card` above, the host can bind `session`-level markup to the component's derived value:
|
|
1558
|
+
|
|
1559
|
+
```html
|
|
1560
|
+
<user-card data-wcs="state: user"></user-card>
|
|
1561
|
+
<span data-wcs="textContent: user.display"></span> <!-- "Alice <alice@example.com>" — the component's getter -->
|
|
1562
|
+
```
|
|
1563
|
+
|
|
1564
|
+
- Row mounts export per row: `$getAll("users.*.display")` and `text: .display` inside the same `for` read each row component's getter. Dependencies flow through: when `user.name` changes, everything that read `user.display` re-renders.
|
|
1565
|
+
- Accessors whose component-local path contains a wildcard, such as `get "children.*.label"()`, work inside the component but are **not exported**. Define `get label()` on a component mounted on each child row instead. Only accessors whose exported path has the mount point's wildcard count are exported.
|
|
1566
|
+
- The parent evaluates before the child component registers, so the first read may see `undefined`; the value converges as soon as the component mounts. Write derived expressions defensively (`(x ?? 0)`).
|
|
1567
|
+
- Missing-path warnings are deferred by one macrotask (`setTimeout(0)`), independently of `getBindingsReady`. With an autoloader or delayed custom-element definition, an initial warning may appear before the component registers, even when the binding eventually resolves.
|
|
1568
|
+
- If the tree already has the key (including an inherited property), the tree wins and the runtime warns once (`wcs/mount-export-shadowed`). Two components exporting the same key on the same instance is a configuration error detected during candidate scans (`wcs/mount-export-ambiguous`). A validated cache hit does not rescan other candidates, so adding a conflicting component after the first resolution may escape detection.
|
|
1569
|
+
- Writing to an exported key from outside runs the accessor's setter, or throws if it only has a getter (the tree never grows a key that would hide the getter). `in` does not see exported keys.
|
|
1570
|
+
- **Self-recursive components** (trees of unbounded depth) become expressible: a component that renders `<template data-wcs="for: children"><tree-node data-wcs="state: ."></tree-node></template>` inside itself can define `get total() { return this.value + this.$getAll("children.*.total").reduce((a, b) => a + (b ?? 0), 0); }` — each level's formula closes over one level, and the ledger resolves the recursion. Paths cannot express recursion themselves (their wildcard count is fixed), so the recursion lives in the DOM and the paths are its unrolled form. Design: [docs/state-overlay-export-design.md](../../docs/state-overlay-export-design.md).
|
|
1571
|
+
|
|
1337
1572
|
### Standalone Web Component Injection (`__e2e__/single-component`)
|
|
1338
1573
|
|
|
1339
1574
|
Even when a component is independent from outer host state, you can inject reactive state with `bind-component`.
|
|
@@ -2124,7 +2359,7 @@ All bindings work inside `<svg>` elements. Use `attr.*` for SVG attributes:
|
|
|
2124
2359
|
|
|
2125
2360
|
## Lifecycle Hooks
|
|
2126
2361
|
|
|
2127
|
-
State objects can define `$connectedCallback`, `$disconnectedCallback`, and `$
|
|
2362
|
+
State objects can define `$connectedCallback`, `$disconnectedCallback`, `$updatedCallback`, and `$errorCallback` for initialization, cleanup, update, and binding-failure handling.
|
|
2128
2363
|
|
|
2129
2364
|
```html
|
|
2130
2365
|
<wcs-state>
|
|
@@ -2154,6 +2389,7 @@ State objects can define `$connectedCallback`, `$disconnectedCallback`, and `$up
|
|
|
2154
2389
|
| `$connectedCallback` | After state initialization on first connect; on every reconnect thereafter | Yes (awaited) |
|
|
2155
2390
|
| `$disconnectedCallback` | When the element is removed from the DOM | No (sync only) |
|
|
2156
2391
|
| `$updatedCallback(paths, indexesListByPath)` | After updates are applied to live bindings | Yes (not awaited) |
|
|
2392
|
+
| `$errorCallback(error, info)` | After a drain in which a binding failed to apply — once per failed binding, after `$updatedCallback` | Yes (not awaited) |
|
|
2157
2393
|
|
|
2158
2394
|
All hooks except `$disconnectedCallback` support `async` — you can use `async/await` in any of them. Since the reactive proxy detects every property assignment as a change, standard `async/await` with direct property updates is sufficient for asynchronous operations — loading flags, fetched data, and error messages are all just property assignments, without requiring additional abstractions for async state management.
|
|
2159
2395
|
|
|
@@ -2161,6 +2397,19 @@ All hooks except `$disconnectedCallback` support `async` — you can use `async/
|
|
|
2161
2397
|
- `$connectedCallback` is called **every time** the element is connected (including re-insertion after removal), making it suitable for setup that should be re-established
|
|
2162
2398
|
- `$disconnectedCallback` is called synchronously — use it for cleanup such as clearing timers, removing event listeners, or releasing resources
|
|
2163
2399
|
- `$updatedCallback(paths, indexesListByPath)` receives the paths whose live bindings were applied in that drain. Unbound state writes do not invoke it or appear in `paths`. For wildcard updates, `indexesListByPath` contains the updated index sets. Marker paths of mounted components (`#m…`) never appear in `paths` — a component's private keys stay private (DevTools shows them in its overlays view). Can be `async`, but the return value is not awaited
|
|
2400
|
+
- `$errorCallback(error, info)` is the in-page **error boundary** for bindings. When applying a binding throws — a path getter or filter threw, a structural directive failed — the failure is isolated (the rest of the batch still applies, and neither the value nor the DOM is rolled back) and, without this hook, reported with `console.error`. Declare the hook and the report comes to you instead: `error` is what was thrown, `info` is `{ path, bindingType, node }` identifying the binding (`path` as written in `data-wcs`, wildcards intact). `this` is the writable state proxy, so the usual shape is to write the message into state and render it like anything else:
|
|
2401
|
+
|
|
2402
|
+
```js
|
|
2403
|
+
export default {
|
|
2404
|
+
user: null, loadError: "",
|
|
2405
|
+
get title() { return this.user.profile.name; }, // throws while user is null
|
|
2406
|
+
$errorCallback(error, { path }) {
|
|
2407
|
+
this.loadError = `${path}: ${error.message}`; // <p data-wcs="textContent: loadError">
|
|
2408
|
+
},
|
|
2409
|
+
};
|
|
2410
|
+
```
|
|
2411
|
+
|
|
2412
|
+
The hook runs after the batch (after `$updatedCallback`), is not awaited, and an exception thrown inside it is reported to the console without breaking the drain. DevTools still receives every failure as `state:binding-apply-error` whether or not the hook exists. Root-only: a volume (`<wcs-state mount>`) declaring it is ignored. It does not cover `$watch` handlers (isolated and reported separately) or errors thrown by `$connectedCallback` / `$updatedCallback` (those fail loudly).
|
|
2164
2413
|
- In Web Components, define `async $stateReadyCallback(stateProp)` to receive a hook when the bound state becomes available via `bind-component`
|
|
2165
2414
|
|
|
2166
2415
|
## Transition animations
|
|
@@ -2218,13 +2467,26 @@ So **no warning is not a proof of correctness.** For exhaustive checking, run `n
|
|
|
2218
2467
|
|
|
2219
2468
|
### Index arity, wildcard rank, and getter cycles are checked too
|
|
2220
2469
|
|
|
2221
|
-
Anything that follows mechanically from the path string is reported at runtime and by the linter under the same diagnostic code.
|
|
2470
|
+
Anything that follows mechanically from the path string is reported at runtime and by the linter under the same diagnostic code. Six of the codes below are **runtime-only** in this release — the linter does not emit them: `wcs/getter-depth-exceeded`, `wcs/index-param-range`, `wcs/recursion-context`, `wcs/recursion-shared-list`, `wcs/recursion-cycle` and `wcs/recursion-depth-exceeded`.
|
|
2222
2471
|
|
|
2223
2472
|
| Diagnostic | What it checks | Fix |
|
|
2224
2473
|
|---|---|---|
|
|
2225
2474
|
| `wcs/index-arity` | `$resolve(path, indexes)` must match the `*` count **exactly**; `$getAll(path, indexes)` / `$setAll(path, indexes, …)` have it as an **upper bound** (fewer is a legitimate prefix meaning "expand the rest") | Match the count |
|
|
2226
2475
|
| `wcs/wildcard-rank` | The path's `*` count (and the N in `$N`) must not exceed the enclosing `for` nesting | Add a `for`, or name the row with `$resolve(path, indexes)` |
|
|
2227
|
-
| `wcs/getter-cycle` | Path getters must not form a dependency cycle | Break the cycle |
|
|
2476
|
+
| `wcs/getter-cycle` | Path getters must not form a dependency cycle. At runtime this is the address stack revisiting an address it already holds | Break the cycle |
|
|
2477
|
+
| `wcs/getter-depth-exceeded` | Getter evaluation nests deeper than the engine evaluates in one pass (128 frames), with no address visited twice — the data is simply that deep | Aggregate in fewer levels, or flatten the tree |
|
|
2478
|
+
| `wcs/index-param-range` | `$N` must name an existing wildcard level: `$1` through `$128`, no leading zeros | Use a level that exists |
|
|
2479
|
+
| `wcs/recursion-unsupported` | `**` reached something that does not interpret it — markup, a `$watch` or `$listKeys` key, `$resolve` / `$postUpdate` / `$trackDependency`, an assignment — or the state declares no `$recursion` at all | Use a concrete path, or declare the anchor |
|
|
2480
|
+
| `wcs/recursion-declaration-invalid` | The `$recursion` declaration or a `**` getter key has a shape this version refuses: an anchor or repeat that is not a list element or carries an index segment (`"nodes.0.items.*"`), more than one anchor, a `**` key that is not a getter or has a setter, `get "nodes.**"`, a getter that names the structure, two getters expanding to one concrete path, a concrete getter with the same name as an expansion. The linter reports it first; at runtime it throws when the declaration is read | Fix the declaration as the message says |
|
|
2481
|
+
| `wcs/recursion-anchor` | A `**` path that does not match the one declared anchor (this version takes exactly one self-recursive anchor per state), or whose suffix after `**` is not well-formed — an empty segment (`nodes.**.`, `nodes.**..x`) or a bare `*` right after `**` (`nodes.**.*`) | Spell the anchor as declared, and a real path after it |
|
|
2482
|
+
| `wcs/recursion-context` | A **bound** `**` was read where there is no depth to bind to — the top level, or a getter outside the anchor | Read it from a recursive or row getter, or pass `[]` to union every depth |
|
|
2483
|
+
| `wcs/recursion-getall-form` / `wcs/recursion-setall-form` | An `indexes` argument `**` cannot define. The code is carried by the non-empty prefix (both APIs) and by a non-array `indexes` on `$getAll` (`null`, a string…); omission, a mapper and `{ spread: true }` in a `$setAll` are the same mistake and the linter reports them under the same code, but at runtime they throw with the form named in the message and no code | Omit for the current depth, `[]` for every depth |
|
|
2484
|
+
| `wcs/recursion-structural-write` | A recursive `$setAll` targets the structure itself — a node, its child list, that list's `length`, a child node, or an object on the way to the child list when the repeating sub-path has several segments | Broadcast to a leaf property instead |
|
|
2485
|
+
| `wcs/recursion-readonly` | A write targets a recursive getter, or a path inside the value it derives — a recursive `$setAll` on `nodes.**.total`, or any write to a concrete expansion such as `nodes.*.children.*.total` (fixed-arity `$setAll`, `$resolve` with a value, direct assignment) | Write what the getter derives from |
|
|
2486
|
+
| `wcs/recursion-shared-list` / `wcs/recursion-cycle` | The walk reached the same array instance twice: two nodes sharing one child list, or a list reachable from its own ancestor | Give every node its own child array |
|
|
2487
|
+
| `wcs/recursion-depth-exceeded` | The expanded path needs more than 128 wildcard levels — the tree nests deeper than the engine can address, or it contains a cycle | Flatten the tree, or find the cycle |
|
|
2488
|
+
|
|
2489
|
+
The form each `wcs/recursion-*` row is refusing — and the form to write instead — is spelled out under [Recursive Paths](#recursive-paths-recursion).
|
|
2228
2490
|
|
|
2229
2491
|
Previously **extra indexes were silently discarded** by both APIs, so a mixed-up call returned a plausible-looking wrong value. Both now throw:
|
|
2230
2492
|
|
|
@@ -2318,6 +2580,22 @@ of the binding expression: `price|locale(fr-FR)`. For a page that switches
|
|
|
2318
2580
|
language without reloading, see [docs/i18n-design.md](../../docs/i18n-design.md) —
|
|
2319
2581
|
the short answer is that translations belong on a path, not in a filter.
|
|
2320
2582
|
|
|
2583
|
+
**Where i18n sits, and what was decided.** There is no i18n package and no live
|
|
2584
|
+
language switch, on purpose. A dictionary is an ES module chosen per locale and mounted as a
|
|
2585
|
+
volume (`<wcs-state mount="i18n" src="/i18n/state.js">`), then read as ordinary
|
|
2586
|
+
paths (`i18n.checkout.title`); the locale is decided **before** the page renders — from
|
|
2587
|
+
`<html lang>` for the filters, and from the URL for the router, where the locale
|
|
2588
|
+
lives in the `basename` (`/ja/…`) rather than in a route parameter. Switching
|
|
2589
|
+
language is therefore a real navigation to another basename, not a state write:
|
|
2590
|
+
the router intercepts links under its own basename only, so a `/:lang` parameter
|
|
2591
|
+
would silently keep the old language, and a live switch would need every locale-
|
|
2592
|
+
dependent module to re-evaluate. The `<base href>` that carries the basename has a
|
|
2593
|
+
real cost (page-fragment anchors, SVG fragment references, relative `src` under
|
|
2594
|
+
CSP all resolve against it), and two alternatives were weighed — the router reading
|
|
2595
|
+
`<html lang>` itself, and a per-link opt-out of interception — and left recorded.
|
|
2596
|
+
Read [docs/i18n-design.md](../../docs/i18n-design.md) §9-1 before choosing a
|
|
2597
|
+
different shape; `examples/router-i18n` is the reference layout.
|
|
2598
|
+
|
|
2321
2599
|
> These three are **architecture-hardening** features; their normative reference is
|
|
2322
2600
|
> `docs/architecture-hardening/`. `enablePropagationContext` defaults **on** — its
|
|
2323
2601
|
> write-path cost is near-zero for one-way bindings (only echo-capable two-way
|