@wcstack/state 3.1.0 → 3.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.
Files changed (56) hide show
  1. package/README.ja.md +128 -90
  2. package/README.md +129 -91
  3. package/dist/auto.min.js +1 -1
  4. package/dist/auto.min.js.map +1 -1
  5. package/dist/define.d.ts +11 -7
  6. package/dist/index.d.ts +58 -15
  7. package/dist/index.esm.js +1 -1
  8. package/dist/index.esm.js.map +1 -1
  9. package/dist/manifest.d.ts +39 -5
  10. package/dist/manifest.esm.js +315 -253
  11. package/dist/parser.d.ts +16 -2
  12. package/dist/parser.esm.js +223 -50
  13. package/dist/split/chunks/binder.js +1 -1
  14. package/dist/split/chunks/binder.js.map +1 -1
  15. package/dist/split/chunks/checkDependency.js +1 -1
  16. package/dist/split/chunks/declarationAliases.js +2 -0
  17. package/dist/split/chunks/declarationAliases.js.map +1 -0
  18. package/dist/split/chunks/declarationHooks.js +2 -0
  19. package/dist/split/chunks/declarationHooks.js.map +1 -0
  20. package/dist/split/chunks/featureBridge.js +2 -0
  21. package/dist/split/chunks/featureBridge.js.map +1 -0
  22. package/dist/split/chunks/filterRegistry.js +1 -1
  23. package/dist/split/chunks/filterRegistry.js.map +1 -1
  24. package/dist/split/chunks/getAllPropertyDescriptors.js +1 -1
  25. package/dist/split/chunks/getAllPropertyDescriptors.js.map +1 -1
  26. package/dist/split/chunks/lifecycleHooks.js +1 -1
  27. package/dist/split/chunks/pathDiagnostics.js +1 -1
  28. package/dist/split/chunks/pathDiagnostics.js.map +1 -1
  29. package/dist/split/chunks/registerComponents.js +1 -1
  30. package/dist/split/chunks/registerComponents.js.map +1 -1
  31. package/dist/split/chunks/setAll.js +1 -1
  32. package/dist/split/chunks/setAll.js.map +1 -1
  33. package/dist/split/chunks/version.js +1 -1
  34. package/dist/split/core.d.ts +21 -9
  35. package/dist/split/core.js +1 -1
  36. package/dist/split/core.js.map +1 -1
  37. package/dist/split/features/devtools.js +1 -1
  38. package/dist/split/features/devtools.js.map +1 -1
  39. package/dist/split/features/diagnostics.js +1 -1
  40. package/dist/split/features/diagnostics.js.map +1 -1
  41. package/dist/split/features/formats.js +1 -1
  42. package/dist/split/features/formats.js.map +1 -1
  43. package/dist/split/features/recursion.js +1 -1
  44. package/dist/split/features/recursion.js.map +1 -1
  45. package/dist/split/features/scopes.js +1 -1
  46. package/dist/split/features/scopes.js.map +1 -1
  47. package/dist/split/features/ssr.js +1 -1
  48. package/dist/split/features/ssr.js.map +1 -1
  49. package/dist/split/features/temporal.js +1 -1
  50. package/dist/split/features/temporal.js.map +1 -1
  51. package/dist/wcs-manifest.json +98 -44
  52. package/package.json +1 -1
  53. package/dist/split/chunks/mount.js +0 -2
  54. package/dist/split/chunks/mount.js.map +0 -1
  55. package/dist/split/chunks/watchRuntime.js +0 -2
  56. package/dist/split/chunks/watchRuntime.js.map +0 -1
package/README.md CHANGED
@@ -88,7 +88,7 @@ This separates UI and state with **no JavaScript intermediary**. You can:
88
88
 
89
89
  - Redesign the UI without touching state logic — as far as the logic does not hang off what is rendered: a live binding is one of the three [demand roots](#demand-roots--what-makes-a-getter-run), so an element you think of as display-only can be the page's only subscription
90
90
  - Refactor state structure and only update path strings
91
- - Read the HTML and know every binding; the dependencies that are not in the HTML (`$watch`, `$streams`, `$scan`) are all declared in one place, the state
91
+ - Read the HTML and know every binding; the dependencies that are not in the HTML (`$watch`, `$stream`, `$scan`) are all declared in one place, the state
92
92
 
93
93
  The path contract works like a URL in a REST API — a simple string that both sides agree on, with no shared code between them. It's the natural result of building on HTML's declarative nature rather than inventing a template language on top of JavaScript.
94
94
 
@@ -132,12 +132,12 @@ Every row is a section of this README. Unless it appears under [Where the neighb
132
132
  | **Aggregation and bulk write** | `$getAll` / `$setAll` / `$resolve` read and write across `items.*.price` without rebuilding the array | [Proxy APIs](#proxy-apis) |
133
133
  | **Recursive paths** | `$recursion` declares where a tree's shape repeats; one `**` getter covers every depth | [Recursive paths](#recursive-paths-recursion) |
134
134
  | **Reactivity** | An ES Proxy tracks reads per address, caches per address, invalidates in dependency order and batches DOM writes on a microtask | [Updating state](#updating-state) · [Dependency tracking boundaries](#dependency-tracking-boundaries) |
135
- | **What makes a getter run** | Getters are lazy. Demand comes from a live binding, a `$watch` or a `$streams` `args` — and from nowhere else | [Demand roots](#demand-roots--what-makes-a-getter-run) |
135
+ | **What makes a getter run** | Getters are lazy. Demand comes from a live binding, a `$watch` or a `$stream` `args` — and from nowhere else | [Demand roots](#demand-roots--what-makes-a-getter-run) |
136
136
  | **Modularity** | `mount=` grafts a module onto the one tree; `state: path` mounts a subtree onto a component; the per-property form maps single keys, and a mounted component's getters are exported at its mount point | [Volumes](#mounting-additional-state-mount) · [Whole-object mount](#whole-object-mount-state-path) |
137
137
  | **Components** | Two mutually exclusive mechanisms: a JavaScript class with `bind-component`, or HTML-only DCC | [Choosing a mechanism](#choosing-a-component-mechanism) |
138
138
  | **Wiring to other elements** | The wc-bindable protocol, spread (`...: obj`), `#init=` / `#sync=` authority, property-to-attribute mirroring | [Binding authority](#binding-authority-init--sync) · [Spread](#spread-binding) · [Inputs](#inputs-and-attribute-mirror) |
139
139
  | **Tokens** | Command tokens call an element's methods from state; event tokens carry the element's events back | [Command token](#command-token-method-binding) · [Event token](#event-token-event-binding) |
140
- | **Time** | `$streams` folds an async source, `$watch` reacts headlessly, `$scan` owns an accumulation that outlives both | [Choosing a time mechanism](#choosing-a-time-mechanism) |
140
+ | **Time** | `$stream` folds an async source, `$watch` reacts headlessly, `$scan` owns an accumulation that outlives both | [Choosing a time mechanism](#choosing-a-time-mechanism) |
141
141
  | **Initialization and lifecycle** | Six ways to supply the state; `$connectedCallback` … `$stateReadyCallback`; `bootstrapState()` / `createState()` | [State initialization](#state-initialization) · [Lifecycle hooks](#lifecycle-hooks) · [API reference](#api-reference) |
142
142
  | **Diagnostics** | Unresolved paths, index arity, wildcard rank and getter cycles are reported; one failing binding stays confined, and neither values nor the DOM are rolled back | [Diagnostics](#diagnostics-and-failure-handling) |
143
143
  | **Delivery** | Zero runtime dependencies, no build step, ESM, one CDN `/auto` tag; no `unsafe-eval`, Trusted Types supported | [Installation](#installation) · [docs/csp.md](../../docs/csp.md) |
@@ -187,15 +187,15 @@ leaves features out, it can compose them instead:
187
187
  <script type="importmap">
188
188
  {
189
189
  "imports": {
190
- "@wcstack/state/core": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.1.0/dist/split/core.js",
191
- "@wcstack/state/features/temporal": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.1.0/dist/split/features/temporal.js",
192
- "@wcstack/state/features/scopes": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.1.0/dist/split/features/scopes.js"
190
+ "@wcstack/state/core": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.3.0/dist/split/core.js",
191
+ "@wcstack/state/features/temporal": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.3.0/dist/split/features/temporal.js",
192
+ "@wcstack/state/features/scopes": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.3.0/dist/split/features/scopes.js"
193
193
  }
194
194
  }
195
195
  </script>
196
196
  <script type="module">
197
197
  import { bootstrapState, installFeatures } from '@wcstack/state/core';
198
- import temporal from '@wcstack/state/features/temporal'; // $watch / $scan / $streams
198
+ import temporal from '@wcstack/state/features/temporal'; // $watch / $scan / $stream
199
199
  import scopes from '@wcstack/state/features/scopes'; // bind-component, mount=, DCC
200
200
 
201
201
  installFeatures([temporal, scopes]);
@@ -203,6 +203,12 @@ leaves features out, it can compose them instead:
203
203
  </script>
204
204
  ```
205
205
 
206
+ Call `installFeatures([...])` **before** `bootstrapState()`, as above: a feature can register a tag of its
207
+ own (`<wcs-ssr>`), and bootstrapping is what defines the tags. The reverse order is not an error — a
208
+ feature installed afterwards catches up and defines its tags against the registries already
209
+ bootstrapped — but keeping the declared order means every tag is defined before the first element
210
+ upgrades.
211
+
206
212
  Load the split form from the package's own files — jsDelivr's plain, version-pinned `/npm/` path as
207
213
  above (it does not read `exports`, so name the file under `dist/split/`), or a bundler — and **never
208
214
  through `esm.run`**. Its `+esm` endpoint re-bundles every entry on the server and inlines the shared
@@ -214,11 +220,11 @@ so the browser evaluates the engine once. Integrity for this form: [docs/sri.md
214
220
  | Entry | What it adds |
215
221
  |---|---|
216
222
  | `@wcstack/state/core` | The binding engine: `data-wcs`, `for` / `if`, path getters, filters, events, `$command` / `$on`, `bootstrapState`, `installFeatures` |
217
- | `@wcstack/state/features/temporal` | `$watch`, `$scan`, `$streams` |
223
+ | `@wcstack/state/features/temporal` | `$watch`, `$scan`, `$stream` |
218
224
  | `@wcstack/state/features/scopes` | `bind-component`, `mount=` volumes, overlay exports, DCC (`data-wc-definition`) |
219
225
  | `@wcstack/state/features/recursion` | `$recursion` and `**` paths |
220
226
  | `@wcstack/state/features/ssr` | `enable-ssr`: server rendering and hydration |
221
- | `@wcstack/state/features/formats` | The formatting filters (`uc`, `date`, `round`, `truncate`, …). The core answers only `not`, which `if` / `else` need |
227
+ | `@wcstack/state/features/formats` | The formatting filters (`upper`, `date`, `round`, `truncate`, …). The core answers only `not`, which `if` / `else` need |
222
228
  | `@wcstack/state/features/devtools` | The DevTools hook protocol source |
223
229
  | `@wcstack/state/features/diagnostics` | Development-time warnings: a bound / `$watch` / `$scan` path that does not resolve on the state is reported with a did-you-mean. Without it the page stays silent — thrown errors keep their full messages either way |
224
230
  | `@wcstack/state/define` | `defineState` and the types only — no runtime at all |
@@ -226,8 +232,11 @@ so the browser evaluates the engine once. Integrity for this form: [docs/sri.md
226
232
  A declaration whose feature is missing does not fail quietly: it throws
227
233
  `[wcs/feature-not-installed] … install it with installFeatures([...]) from "@wcstack/state/features/…"`
228
234
  when the state is defined, and a filter with no implementation throws `[wcs/filter-unknown]` when the
229
- bindings are planned. Installing is idempotent, and every entry shares one core chunk (a feature
230
- never carries a second copy of the engine).
235
+ bindings are planned. A volume (`<wcs-state mount=…>`) declaring `$watch` throws the same
236
+ `[wcs/feature-not-installed]` when it grafts, because a volume's state never passes through the
237
+ element's own declaration gate. Installing is idempotent, and every entry shares one core chunk —
238
+ a feature never carries a second copy of the engine, and never a copy of another feature either
239
+ (a call across that seam goes through a receptacle the owning feature fills on install).
231
240
 
232
241
  ## Basic Usage
233
242
 
@@ -334,7 +343,7 @@ There is **one state tree per root**. To split state across modules, mount a vol
334
343
  <div data-wcs="textContent: cart.total"></div>
335
344
  ```
336
345
 
337
- 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). If the root `<wcs-state>` fails to initialize, the volumes already waiting for it settle with a report of their own instead of waiting forever. That report is the end of the line for those volumes: a volume reported as an orphan does not graft itself later, so connecting a corrected root afterwards does not bring it back. A volume that settles without grafting — orphaned, failed to load, or failed to graft — releases its mount slot, and so does a volume detached while it is still loading or waiting for its root. Such a volume takes the slot back when it is re-attached to the same root, or otherwise just before it grafts, and still grafts as before when the slot is free — even while detached; if another volume took the slot in the meantime, it reports that and does not graft. A synchronous throw from a volume's `$connectedCallback` is reported like an asynchronous one, and the volume counts as grafted. To recover without reloading the page, remove the broken root and the orphaned volumes and add new elements. A grafted volume keeps its slot even when detached, because its data stays in the tree. 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.
346
+ A volume may declare getters, `$watch`, `$listKeys`, `$renderedCallback`, 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). If the root `<wcs-state>` fails to initialize, the volumes already waiting for it settle with a report of their own instead of waiting forever. That report is the end of the line for those volumes: a volume reported as an orphan does not graft itself later, so connecting a corrected root afterwards does not bring it back. A volume that settles without grafting — orphaned, failed to load, or failed to graft — releases its mount slot, and so does a volume detached while it is still loading or waiting for its root. Such a volume takes the slot back when it is re-attached to the same root, or otherwise just before it grafts, and still grafts as before when the slot is free — even while detached; if another volume took the slot in the meantime, it reports that and does not graft. A synchronous throw from a volume's `$connectedCallback` is reported like an asynchronous one, and the volume counts as grafted. To recover without reloading the page, remove the broken root and the orphaned volumes and add new elements. A grafted volume keeps its slot even when detached, because its data stays in the tree. 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.
338
347
 
339
348
  **Injecting root paths into a volume (3.1).** When a volume's code needs a path outside its own subtree, write the injection on the volume element's `data-wcs`, in the same form as a component's partial mount:
340
349
 
@@ -342,7 +351,7 @@ A volume may declare getters, `$watch`, `$listKeys`, `$updatedCallback`, and `$c
342
351
  <wcs-state mount="cart" src="./cart.js" data-wcs="state.taxRate: settings.taxRate"></wcs-state>
343
352
  ```
344
353
 
345
- Inside `cart.js` — getters, methods, `$watch`, `$listKeys` and the lifecycle callbacks — `this.taxRate` reads and writes the root's `settings.taxRate`. The read records a dependency, so `get total()` re-evaluates when `settings.taxRate` changes. `$watch: { taxRate() {…} }` fires on changes to `settings.taxRate`, and `$updatedCallback` receives that update under the inner name `taxRate`. The injected name exists only inside the volume's code: the page keeps reading `settings.taxRate`, and the tree has no `cart.taxRate`.
354
+ Inside `cart.js` — getters, methods, `$watch`, `$listKeys` and the lifecycle callbacks — `this.taxRate` reads and writes the root's `settings.taxRate`. The read records a dependency, so `get total()` re-evaluates when `settings.taxRate` changes. `$watch: { taxRate() {…} }` fires on changes to `settings.taxRate`, and `$renderedCallback` receives that update under the inner name `taxRate`. The injected name exists only inside the volume's code: the page keeps reading `settings.taxRate`, and the tree has no `cart.taxRate`.
346
355
 
347
356
  - **One key at a time.** The left side is a single `state.<key>`. `state: …` cannot move the whole volume; change `mount` for that.
348
357
  - **The injection wins over the volume's own key.** A data key of the same name (`taxRate: 0`) is not grafted. An accessor or method of the same name would make `this.taxRate` ambiguous, so that volume reports an error and does not graft.
@@ -355,8 +364,8 @@ Inside `cart.js` — getters, methods, `$watch`, `$listKeys` and the lifecycle c
355
364
  |---|---|---|---|
356
365
  | Data keys, getters, setters, methods | yes | yes, relative to `p` | yes — own keys are private unless the host maps them; getters are exported |
357
366
  | `$connectedCallback` / `$disconnectedCallback` | yes | yes, relative to `p` | yes |
358
- | `$watch`, `$listKeys`, `$updatedCallback` | yes | yes, relative to `p` | not run — one `wcs/mount-dollar-declaration` warning |
359
- | `$streams`, `$scan`, `$recursion`, `**` getters | yes | rejected with an error before grafting | not run — warning |
367
+ | `$watch`, `$listKeys`, `$renderedCallback` | yes | yes, relative to `p` | not run — one `wcs/mount-dollar-declaration` warning |
368
+ | `$stream`, `$scan`, `$recursion`, `**` getters | yes | rejected with an error before grafting | not run — warning |
360
369
  | `$commandTokens`, `$eventTokens`, `$on` | yes | not run — warning | not run — warning |
361
370
  | `$errorCallback` | yes | not run — warning (silent before 3.0) | not run — warning (silent before 3.0) |
362
371
  | An initialization failure | reported once; `connectedCallbackPromise` rejects | settles without grafting and releases its slot; `connectedCallbackPromise` resolves (a missing `scopes` feature rejects it) | reported once; the component's `connectedCallbackPromise` rejects |
@@ -653,7 +662,9 @@ Runtime reads `customClass.wcBindable.properties + inputs` and expands each name
653
662
  <wcs-fetch data-wcs="...: usersFetch; status: alternateStatus"></wcs-fetch>
654
663
  ```
655
664
 
656
- **`undefined` is "no opinion"** — when an expanded state path resolves to `undefined` (e.g. the slot object doesn't initialize that input), the property write is **skipped** and the element keeps its own default. You only need to initialize the paths you actually use; `usersFetch: { value: null, loading: false }` is enough even though `<wcs-fetch>` also declares `method` / `manual` / `body`. To explicitly clear a value, assign `null` — `null` is always written. (This skip applies to every property binding that feeds an element input, not just spread; with `config.debug` each skipped write is logged via `console.debug`.) **Display surfaces are different (3.0):** `textContent` / `innerText` / `innerHTML`, mustache text, `attr.*` and `style.*` have no element default worth keeping, so `undefined` and `null` both mean "no value" there — the text becomes empty and the attribute or style is removed. Before 3.0 a `textContent:` binding skipped `undefined` too, which left the previous row's text in a reused list row, and an attribute got the string `"undefined"` / `"null"`.
665
+ **`undefined` is "no opinion"** — when an expanded state path resolves to `undefined` (e.g. the slot object doesn't initialize that input), the property write is **skipped** and the element keeps its own default. You only need to initialize the paths you actually use; `usersFetch: { value: null, loading: false }` is enough even though `<wcs-fetch>` also declares `method` / `manual` / `body`. To explicitly clear a value, assign `null` — `null` is always written. (This skip applies to every property binding that feeds an element input, not just spread; with `config.debug` each skipped write is logged via `console.debug`.) **Display surfaces are different (3.0):** `textContent` / `innerText` / `innerHTML`, mustache text, `attr.*`, `style.*` and `class.*` have no element default worth keeping, so `undefined` and `null` both mean "no value" there — the text becomes empty, the attribute or style is removed, and the class is taken off. Before 3.0 a `textContent:` binding skipped `undefined` too, which left the previous row's text in a reused list row, and an attribute got the string `"undefined"` / `"null"`. `class.*` is the narrow case: only `undefined` and `null` mean "no value"; **any other non-boolean still throws**, because a class binding is a boolean toggle and a truthy string is far more likely a mistake than an intent. Write `class.on: flag|truthy` when you mean "treat it as truthy".
666
+
667
+ **Filters keep the contract.** The emptiness test runs on the value *after* the filters, so a filter that builds its result with `String(value)` used to turn an absent value back into the characters `"undefined"` — `attr.title: x|trim` wrote `title="undefined"` where the unfiltered `attr.title: x` removes the attribute. The formatting family (`upper`, `lower`, `capitalize`, `trim`, `slice`, `substr`, `padStart`, `padEnd`, `repeat`, `reverse`, `truncate`, `unit`) now passes `undefined` / `null` straight through, so the apply side still empties the text and removes the attribute. Three groups deliberately do **not**: the filters for which an absent value *is* the input (`defaults`, `coalesce`, `nullIfEmpty`, `boolean`, `truthy`, `falsy`, `not`, `eq`, `ne`), the conversions whose whole job is to convert (`int`, `float`, `number`, `string` — `undefined|string` is still `"undefined"`), and the filters that check the value's type (the number, date and array families, e.g. `toFixed` / `date` / `join`). Those last ones throw, which is confined to the one binding and reported through `$errorCallback` — put a `coalesce` in front when the value can legitimately be absent (`price|coalesce(0)|toFixed(2)`).
657
668
 
658
669
  **Constraints**:
659
670
 
@@ -727,7 +738,7 @@ Inside a `for` loop, paths starting with `.` are expanded relative to the loop's
727
738
  |---|---|---|
728
739
  | `.name` | `users.*.name` | Property of the current element |
729
740
  | `.` | `users.*` | The current element itself |
730
- | `.name\|uc` | `users.*.name\|uc` | Filters are preserved |
741
+ | `.name\|upper` | `users.*.name\|upper` | Filters are preserved |
731
742
 
732
743
  For primitive arrays, `.` refers to the element value directly:
733
744
 
@@ -1060,9 +1071,9 @@ The rule: **read only through `this`, and don't write state or touch the DOM fro
1060
1071
 
1061
1072
  | API | Use it for |
1062
1073
  |---|---|
1063
- | `this.$trackDependency(path)` | Register an extra dependency so this getter is dirtied when that path changes |
1074
+ | `this.$dependOn(path)` | Register an extra dependency so this getter is dirtied when that path changes |
1064
1075
  | `this.$postUpdate(path)` | Announce that an untracked input changed, from outside the getter |
1065
- | `this.$untrackDependency(fn)` | Read a path *without* registering it as a dependency (the inverse) |
1076
+ | `this.$untracked(fn)` | Read a path *without* registering it as a dependency (the inverse) |
1066
1077
  | `this.$eq(path, key)` / `$eqPath(path, keyPath)` / `$eqIndex(path)` | A keyed subscription: "is `path` equal to this row's key?" without a pattern dependency (see [Keyed selection](#keyed-selection-eq--eqpath--eqindex)) |
1067
1078
 
1068
1079
  ```javascript
@@ -1086,9 +1097,9 @@ Three rules decide what the dependency graph sees. None of them matters until yo
1086
1097
  | **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 |
1087
1098
  | **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) |
1088
1099
 
1089
- 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.
1100
+ 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 `$stream` fold — is left alone.
1090
1101
 
1091
- `$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.
1102
+ `$untracked(fn)` applies the setter rule to a getter on purpose: reads inside `fn` are not tracked. `$dependOn(path)` is the escape hatch for the first rule.
1092
1103
 
1093
1104
  ### Keyed selection (`$eq` / `$eqPath` / `$eqIndex`)
1094
1105
 
@@ -1121,6 +1132,7 @@ What reaches the rows:
1121
1132
  - **A write to an object above `path`** — `this.sel = { id: 2 }` for `$eq("sel.id", …)`: the same two rows, keyed by the value `path` had under the old object and has under the new one.
1122
1133
  - **A `path` that is a getter, or sits under one** (`$eq("current.id", …)` with `get current()`): its value changes without a write to it, so the calls fall back to an ordinary tracked read. The selection stays correct, but a change re-evaluates every row, as without the keyed form. Point `path` at the written state (`selectedId`) to keep the two-row cost. The State pane of `@wcstack/devtools` counts the subscriptions per path under **Keyed selection** and marks a path that fell back this way with a `tracked` badge (3.0).
1123
1134
  - **No type conversion:** `"2"` does not match the id `2`. An `<input>` or `<select>` writes strings, so convert on the way in (`value|number: selectedId`) or keep the ids as strings.
1135
+ - **Inside a scope, `path` is relative** — like every other `$` API that takes a path. In a mounted component (`bind-component`) and in a volume (`<wcs-state mount="cart">`), `$eq("selectedId", …)`, `$eqPath`'s two paths, `$eqIndex` and `$dependOn` all resolve against that scope (`cart.selectedId`), not against the root. `$untracked` takes a callback, so nothing is translated there.
1124
1136
 
1125
1137
  ### Loop Index Variables (`$1`, `$2`, ...)
1126
1138
 
@@ -1152,7 +1164,7 @@ You can also display the loop index directly in templates:
1152
1164
 
1153
1165
  ```html
1154
1166
  <template data-wcs="for: items">
1155
- <td>{{ $1|inc(1) }}</td> <!-- 1-based row number -->
1167
+ <td>{{ $1|add(1) }}</td> <!-- 1-based row number -->
1156
1168
  </template>
1157
1169
  ```
1158
1170
 
@@ -1166,8 +1178,8 @@ Inside state objects (getters / methods), the following APIs are available via `
1166
1178
  | `this.$setAll(path, indexes, value, options?)` | Write to every address matching a wildcard path |
1167
1179
  | `this.$resolve(path, indexes, value?)` | Resolve a wildcard path with specific indexes |
1168
1180
  | `this.$postUpdate(path)` | Manually trigger update notification for a path |
1169
- | `this.$trackDependency(path)` | Manually register a dependency for cache invalidation |
1170
- | `this.$untrackDependency(fn)` | Read values inside fn without registering dependencies (symmetric to `$trackDependency`) |
1181
+ | `this.$dependOn(path)` (3.2; old name `$trackDependency`) | Manually register a dependency for cache invalidation |
1182
+ | `this.$untracked(fn)` (3.2; old name `$untrackDependency`) | Read values inside fn without registering dependencies |
1171
1183
  | `this.$eq(path, key)` / `$eqPath(path, keyPath)` / `$eqIndex(path, level?)` | Keyed subscription for "is this row's key the value of `path`?" (see [Keyed selection](#keyed-selection-eq--eqpath--eqindex)) |
1172
1184
  | `this.$command.<name>` | Access a `CommandToken` declared in `$commandTokens` (see [Command Token](#command-token-method-binding)) |
1173
1185
  | `this.$stateElement` | Access to the `IStateElement` instance |
@@ -1339,7 +1351,7 @@ k=2 nodes.*.children.*.children.*
1339
1351
  | `$getAll(path, [])`, **explicit** | **Union of every depth** — depth-first, pre-order, ascending index |
1340
1352
  | `$getAll(path, [i, …])` | Rejected: a prefix cannot say which depth it applies to (`wcs/recursion-getall-form`) |
1341
1353
  | `$setAll(path, [], value)` | Broadcast to every depth, in that same order |
1342
- | `$resolve`, `$postUpdate`, `$trackDependency`, `$watch` keys, `$listKeys` keys, `data-wcs` in markup, direct assignment | Rejected (`wcs/recursion-unsupported`) |
1354
+ | `$resolve`, `$postUpdate`, `$dependOn`, `$watch` keys, `$listKeys` keys, `data-wcs` in markup, direct assignment | Rejected (`wcs/recursion-unsupported`) |
1343
1355
 
1344
1356
  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.
1345
1357
 
@@ -1408,8 +1420,8 @@ Every other form is refused *before* the walk writes anything, so a rejected cal
1408
1420
  | omitted indexes | The write API takes no context, so there is no depth to bind to — pass `[]` |
1409
1421
  | a mapper function | `(current, ...indexes)` has a different arity at every depth |
1410
1422
  | `{ spread: true }` | Handing a flat array to a tree needs the author to know the walk order |
1411
- | `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`) |
1412
- | `nodes.**.total`, or a path inside its value | A recursive getter has no setter — write what it derives from (`wcs/recursion-readonly`) |
1423
+ | `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, so `nodes.**.children.0` is a child node | Writing the structure itself (assigning `length` truncates the list) invalidates the child addresses this very write already resolved (`wcs/recursion-structural-write`) |
1424
+ | `nodes.**.total`, or a path inside its value — index spellings fold to the same form too, so `nodes.**.children.0.total` is the getter | A recursive getter has no setter — write what it derives from (`wcs/recursion-readonly`) |
1413
1425
 
1414
1426
  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.
1415
1427
 
@@ -1465,7 +1477,7 @@ Each of these is a diagnostic, never a silent reinterpretation:
1465
1477
  - 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)
1466
1478
  - 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
1467
1479
  - 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
1468
- - `**` in `data-wcs`, in `$watch` or `$listKeys` keys, or in `$resolve` / `$postUpdate` / `$trackDependency`
1480
+ - `**` in `data-wcs`, in `$watch` or `$listKeys` keys, or in `$resolve` / `$postUpdate` / `$dependOn`
1469
1481
  - `$recursion` and `**` getters in a volume (`mount=`) or a mounted component (`bind-component`) — declare them on the root state
1470
1482
  - A recursive `<template>`, a `$depth` variable, and a public `maxDepth` option — none of the three exist
1471
1483
 
@@ -1501,7 +1513,9 @@ export default {
1501
1513
 
1502
1514
  ## Filters
1503
1515
 
1504
- 46 built-in filters are available for both input (DOM → state) and output (state → DOM) directions.
1516
+ 48 built-in filters are available for both input (DOM → state) and output (state → DOM) directions.
1517
+
1518
+ **3.2 renamed some filters** (requirement B12). The tables show the canonical names, with the old name in parentheses. The old names work the same through 3.x and are removed in 4.0. Lint and the VS Code extension flag an old name with an info diagnostic (`wcs/name-alias`) that suggests the canonical one. On a page that uses 3.1 or earlier, write the old names.
1505
1519
 
1506
1520
  ### Comparison
1507
1521
 
@@ -1509,7 +1523,7 @@ export default {
1509
1523
  |---|---|---|
1510
1524
  | `eq(value)` | Equal | `count\|eq(0)` → `true/false` |
1511
1525
  | `ne(value)` | Not equal | `count\|ne(0)` |
1512
- | `not` | Boolean NOT | `isActive\|not` |
1526
+ | `not` | Invert truthiness — `0`, `""`, `null` and `undefined` all give `true` (3.x; it used to throw on a non-boolean, which made `else:` render neither branch) | `isActive\|not` |
1513
1527
  | `lt(n)` | Less than | `count\|lt(10)` |
1514
1528
  | `le(n)` | Less than or equal | `count\|le(10)` |
1515
1529
  | `gt(n)` | Greater than | `count\|gt(0)` |
@@ -1519,8 +1533,8 @@ export default {
1519
1533
 
1520
1534
  | Filter | Description | Example |
1521
1535
  |---|---|---|
1522
- | `inc(n)` | Add | `count\|inc(1)` |
1523
- | `dec(n)` | Subtract | `count\|dec(1)` |
1536
+ | `add(n)` (`inc`) | Add | `count\|add(1)` |
1537
+ | `sub(n)` (`dec`) | Subtract | `count\|sub(1)` |
1524
1538
  | `mul(n)` | Multiply | `price\|mul(1.1)` |
1525
1539
  | `div(n)` | Divide | `total\|div(100)` |
1526
1540
  | `mod(n)` | Modulo | `index\|mod(2)` |
@@ -1531,7 +1545,7 @@ export default {
1531
1545
 
1532
1546
  | Filter | Description | Example |
1533
1547
  |---|---|---|
1534
- | `fix(n)` | Fixed decimal places | `price\|fix(2)` → `"100.00"` |
1548
+ | `toFixed(n)` (`fix`) | Fixed decimal places | `price\|toFixed(2)` → `"100.00"` |
1535
1549
  | `round(n?)` | Round | `value\|round(2)` |
1536
1550
  | `floor(n?)` | Floor | `value\|floor` |
1537
1551
  | `ceil(n?)` | Ceiling | `value\|ceil` |
@@ -1543,16 +1557,17 @@ export default {
1543
1557
 
1544
1558
  | Filter | Description | Example |
1545
1559
  |---|---|---|
1546
- | `uc` | Upper case | `name\|uc` |
1547
- | `lc` | Lower case | `name\|lc` |
1548
- | `cap` | Capitalize | `name\|cap` |
1560
+ | `upper` (`uc`) | Upper case | `name\|upper` |
1561
+ | `lower` (`lc`) | Lower case | `name\|lower` |
1562
+ | `capitalize` (`cap`) | Capitalize | `name\|capitalize` |
1549
1563
  | `trim` | Trim whitespace | `text\|trim` |
1550
1564
  | `slice(n)` | Slice string | `text\|slice(5)` |
1551
- | `substr(start, length)` | Substring | `text\|substr(0,10)` |
1552
- | `pad(n, char?)` | Pad start | `id\|pad(5,0)` → `"00001"` |
1553
- | `rep(n)` | Repeat | `text\|rep(3)` |
1554
- | `rev` | Reverse | `text\|rev` |
1555
- | `truncate(n, suffix?)` | Shorten and append an ellipsis | `title\|truncate(20)` |
1565
+ | `substr(start, length)` | Substring (both arguments required) | `text\|substr(0,10)` |
1566
+ | `padStart(n, char?)` (`pad`) | Pad the start (default `0`) | `id\|padStart(5,0)` → `"00001"` |
1567
+ | `padEnd(n, char?)` | Pad the end (default a space; 3.2) | `code\|padEnd(8)` |
1568
+ | `repeat(n)` (`rep`) | Repeat | `text\|repeat(3)` |
1569
+ | `reverse` (`rev`) | Reverse | `text\|reverse` |
1570
+ | `truncate(n, suffix?)` | Shorten and append an ellipsis (default `…`, one U+2026 character) | `title\|truncate(20)` |
1556
1571
  | `join(sep?)` | Join an array (default `", "`) | `tags\|join` / `tags\|join(/)` |
1557
1572
 
1558
1573
  ### Type Conversion
@@ -1564,17 +1579,19 @@ export default {
1564
1579
  | `boolean` | To boolean | `value\|boolean` |
1565
1580
  | `number` | To number | `value\|number` |
1566
1581
  | `string` | To string | `value\|string` |
1567
- | `null` | To null | `value\|null` |
1582
+ | `nullIfEmpty` (`null`) | Turn an empty string into `null` (anything else passes through) | `value\|nullIfEmpty` |
1568
1583
 
1569
1584
  ### Date / Time
1570
1585
 
1571
1586
  | Filter | Description | Example |
1572
1587
  |---|---|---|
1573
- | `date(loc?)` | Date format | `timestamp\|date` / `timestamp\|date(ja-JP)` |
1574
- | `time(loc?)` | Time format | `timestamp\|time` |
1575
- | `datetime(loc?)` | Date + Time | `timestamp\|datetime(en-US)` |
1576
- | `ymd(sep?)` | YYYY-MM-DD | `timestamp\|ymd` / `timestamp\|ymd(/)` |
1577
- | `hms(sep?)` | HH:MM:SS | `timestamp\|hms` / `timestamp\|hms(-)` |
1588
+ | `date(loc?)` | Date format | `createdAt\|date` / `createdAt\|date(ja-JP)` |
1589
+ | `time(loc?)` | Time format | `createdAt\|time` |
1590
+ | `datetime(loc?)` | Date + Time | `createdAt\|datetime(en-US)` |
1591
+ | `ymd(sep?)` | YYYY-MM-DD (default `-`) | `createdAt\|ymd` / `createdAt\|ymd(/)` |
1592
+ | `hms(sep?)` | HH:MM:SS (default `:`) | `createdAt\|hms` / `createdAt\|hms(-)` |
1593
+
1594
+ **All five take a `Date`,** not a timestamp — a value of any other type is rejected with "requires a date value". State loaded from JSON carries a number or an ISO string, so convert it where it is read rather than in the binding: `get createdAt() { return new Date(this.createdAtMs); }`. `loc?` defaults to `config.locale`, and the default is re-read on **every** apply, so a locale settled after the bindings were planned still takes effect (an explicit `date(ja-JP)` is part of the binding expression and is fixed when it is planned).
1578
1595
 
1579
1596
  ### Boolean / Default
1580
1597
 
@@ -1582,7 +1599,8 @@ export default {
1582
1599
  |---|---|---|
1583
1600
  | `truthy` | Truthy check — JavaScript's own truthiness, the same as `boolean` (so `0n` is falsy since 3.0) | `value\|truthy` |
1584
1601
  | `falsy` | Falsy check (JavaScript's truthiness; `defaults` uses the same test) | `value\|falsy` |
1585
- | `defaults(v)` | Fallback value | `name\|defaults(Anonymous)` |
1602
+ | `defaults(v)` | Fallback for any falsy value (`0`, `false` and `""` included) | `name\|defaults(Anonymous)` |
1603
+ | `coalesce(v)` | Fallback for `null` / `undefined` only (`0`, `false` and `""` stay; 3.2) | `count\|coalesce(0)` |
1586
1604
 
1587
1605
  ### Filter Chaining
1588
1606
 
@@ -1594,7 +1612,15 @@ Filters can be chained with `|`:
1594
1612
 
1595
1613
  A filter is resolved when the bindings are planned. An unknown name throws `[wcs/filter-unknown]` (with a did-you-mean), and — as of 3.0 — an argument count outside what the filter accepts throws `[wcs/filter-arity]` (`join(a,b)`: "accepts at most 1 argument(s) (2 given)"), the same code and bounds lint reports. Arguments are cached by their structure, so `join('a,b')` and `join(a)` are never confused.
1596
1614
 
1597
- **Argument literals are typed (3.0).** An unquoted `true`, `false`, `null` or number is that value; a quoted argument is a string. The comparison filters use it for booleans and `null`: `done|eq(true)` matches `true` (before 3.0 it compared with the string `"true"` and never matched), `eq('true')` does not, and `eq(null)` matches `null`. Numbers and strings compare as before — a numeric value against the number, a string value against the text — so a form value `"1"` still matches `eq(1)`. `defaults(v)` returns the typed value: `defaults(0)` gives `0`, `defaults('0')` gives `"0"`, `defaults(null)` gives `null`.
1615
+ **Argument literals are typed (3.0).** An unquoted `true`, `false`, `null` or number is that value; a quoted argument is a string. The comparison filters use it for booleans and `null`: `done|eq(true)` matches `true` (before 3.0 it compared with the string `"true"` and never matched), `eq('true')` does not, and `eq(null)` matches `null`. Numbers and strings compare as before — a numeric value against the number, a string value against the text — so a form value `"1"` still matches `eq(1)`. A **numeric** value against a typed `true` / `false` / `null` is simply "not equal": `selectedId|eq(null)` is `true` while the id is `null` and `false` once it is a number. (Until 3.2 that combination threw `requires a number as option` on every apply, so a path that is sometimes `null` and sometimes numeric broke the binding the moment a number arrived.) Only an unquoted non-number — `eq(abc)` against a numeric value — still reports the type mismatch; `eq` accepts values of any type, so `status|eq(active)` cannot be judged before the value is known. `defaults(v)` returns the typed value: `defaults(0)` gives `0`, `defaults('0')` gives `"0"`, `defaults(null)` gives `null`.
1616
+
1617
+ **Quoting rules.** Quotes (`'` or `"`) mark a literal, and the argument grammar is deliberately small:
1618
+
1619
+ - Inside quotes, `,` `;` `|` and `:` are ordinary characters, so `join(', ')`, `join(': ')` and `join(';')` all parse.
1620
+ - Whitespace is trimmed **outside** the quotes only: `padStart(5, ' ')` pads with a space, `toFixed( 2 )` is `toFixed(2)`.
1621
+ - **There is no escape character.** A quote of the same kind cannot appear inside its own literal — write `"it's"` rather than `'it\'s'`. (`'it\'s'` is an unterminated quote and throws `[wcs/binding-syntax]`.)
1622
+ - Adjacent runs are concatenated into one argument: `'a' 'b'` is the single string `a b`, and `1'2'` is the string `12` (any quote in the argument makes the whole argument a string).
1623
+ - An unterminated quote is rejected; a trailing empty argument is dropped (`filter()` takes no arguments), while a leading or middle empty argument keeps its position (`defaults(,)` passes one empty string).
1598
1624
 
1599
1625
  ## Web Component Binding
1600
1626
 
@@ -1716,7 +1742,11 @@ customElements.define("user-card", UserCard);
1716
1742
  > component that declares a default for a mapped key (`state = { message: "" }` together with
1717
1743
  > `state.message: ...`) reads the host value; the default is simply not used. (In 2.x R1 made
1718
1744
  > that own key private and it hid the host value, with a `wcs/mount-own-key-shadow` warning.)
1719
- > R1 still keeps every *unmapped* own key private.
1745
+ > R1 still keeps every *unmapped* own key private. This is about a **one-segment** entry: a deeper
1746
+ > entry (`state.a.b: outer.b`) does not touch the privacy of `a`, so if the component declares `a`
1747
+ > itself, `a` and everything under it stay private and the entry never reaches it. The same holds
1748
+ > for an entry whose name is a getter, a setter or a method of the component — the component's own
1749
+ > surface wins. Each of those shapes is reported once as `wcs/mount-own-key-shadow`; rename one side.
1720
1750
  >
1721
1751
  > **`#ro` on a mount is honoured (3.0):** `state#ro: user` or `state.title#ro: doc.title`
1722
1752
  > lets the component read the entry but not write it — `element.state.title = …`, `this.title = …`
@@ -1783,7 +1813,7 @@ customElements.define("my-component", MyComponent);
1783
1813
  - `<wcs-state>` with `bind-component` must be a **direct child** of the component element (top-level)
1784
1814
  - The parent element must be a **custom element** (tag name containing a hyphen)
1785
1815
  - Light DOM components must be wired from the host (the plain, unwired form was removed in v2)
1786
- - A **mounted** scope does not execute declaration surfaces: `$watch`, `$streams` and `$scan` are ignored there with a one-time warning, and `$recursion` / `**` getters are rejected. Declare them on the root state — a volume (`<wcs-state mount>`) can host `$watch`, while `$scan` and `$recursion` are root-only. An unwired Shadow DOM child owns an independent tree and can declare all of them
1816
+ - A **mounted** scope does not execute declaration surfaces: `$watch`, `$stream` and `$scan` are ignored there with a one-time warning, and `$recursion` / `**` getters are rejected. Declare them on the root state — a volume (`<wcs-state mount>`) can host `$watch`, while `$scan` and `$recursion` are root-only. An unwired Shadow DOM child owns an independent tree and can declare all of them
1787
1817
 
1788
1818
  ### Loop with Components
1789
1819
 
@@ -1840,7 +1870,7 @@ that only passes the array through — running no `for:` of its own — still le
1840
1870
  from the owning scope reach the rows at the bottom.
1841
1871
 
1842
1872
  A component's author never has to know how deeply it is placed. `$1`, event-handler indexes,
1843
- `$updatedCallback` and `$getAll` all report positions **within the component's own scope**.
1873
+ `$renderedCallback` and `$getAll` all report positions **within the component's own scope**.
1844
1874
 
1845
1875
  ## Command Token (Method Binding)
1846
1876
 
@@ -1954,9 +1984,12 @@ interface CommandToken {
1954
1984
 
1955
1985
  `emit` returns an array of return values from each subscriber (in subscribe order). For `Promise`-returning methods, wrap with `Promise.all(token.emit(...))` to await all of them.
1956
1986
 
1987
+ **One subscriber throwing does not stop the others.** A token is a fan-out point and the subscribers do not know about each other, so `emit` calls every one of them: a subscriber that throws is reported with `console.error` (naming the token) and leaves `undefined` at its position in the result array, and the remaining subscribers still receive the call. The exception does **not** reach the emitter — the same rule as a state event handler, because the firing path does not wait for the handler and a DOM-event origin has no caller to throw back to. If a caller needs to know, have the subscriber return a value (or a rejected `Promise`) rather than throw. A `Promise`-returning subscriber that rejects is not caught here at all; that is what `Promise.all(token.emit(...))` is for.
1988
+
1957
1989
  ### Subscription Lifecycle
1958
1990
 
1959
- - The subscriber holds the element via `WeakRef`, so a removed element can still be garbage collected even while it remains in the token's subscriber set
1991
+ - The subscriber holds the element via `WeakRef`, so nothing in the token's subscriber set keeps the element alive **by itself**
1992
+ - **Caveat (current implementation):** the subscriber closure also captures its `binding`, and `binding.node` is that same element — a strong reference that defeats the `WeakRef` above. In practice a removed element is retained until its binding is released, so the `WeakRef` only pays off where the binding is gone but the token still holds the subscriber. Releasing the subscriber when its row or element goes away (rather than relying on the lazy purge below) is the fix; it is not in 3.x because the binding machinery has no element-lifecycle hook to hang it on, and the change belongs with that hook. Treat the lazy purge as the mechanism you can rely on today
1960
1993
  - On `emit`, if the WeakRef has been collected or the element is no longer connected (`isConnected === false`), the subscription is purged automatically (lazy purge)
1961
1994
  - Disconnecting the owning `<wcs-state>` keeps the token registry, so the subscriptions still receive commands after the root `<wcs-state>` is re-attached (for example when its host moves in the DOM). While it is disconnected, the state cannot be created, so nothing emits through `$command`
1962
1995
 
@@ -1982,7 +2015,7 @@ A command token does not have to be emitted from state code. A DOM event binding
1982
2015
 
1983
2016
  This is pure wiring: the event endpoint is connected to a command-token endpoint, with no logic in between. The `emit` arguments are passed through exactly like a handler call — the DOM `Event` first, then any enclosing list indexes — so subscribers receive `(event, ...listIndexes)`. Inside a subscriber, pull what you need from the event (`event.target.value`, `event.detail`, …).
1984
2017
 
1985
- - The right-hand side must be `$command.<name>` with `<name>` declared in `$commandTokens`. A path that does not resolve to a `CommandToken` (e.g. a typo) throws at event time.
2018
+ - The right-hand side must be `$command.<name>` with `<name>` declared in `$commandTokens`. A path that does not resolve to a `CommandToken` (e.g. a typo) fails at event time. A DOM event cannot be thrown back at, so it is reported once per event with `console.error` rather than propagated (the same landing as a state handler that rejects).
1986
2019
  - Modifiers work unchanged: `onclick#prevent: $command.someToken` calls `preventDefault()` before emitting (`#stop` likewise).
1987
2020
  - This emits the same token the state emits, so element subscribers wired with `command.<method>: $command.someToken` receive it regardless of who pulled the trigger.
1988
2021
 
@@ -2144,20 +2177,22 @@ The next four sections answer four different questions, and the usual mistake is
2144
2177
  | Declaration | What you declare | Owns a value | Fires | Typical use |
2145
2178
  |---|---|---|---|---|
2146
2179
  | [Path getter](#path-getters-computed-properties) | What a value **is**, in terms of the current state | No — it is recomputed and cached per address | Lazily, when a demand root reads it | Subtotals, classification, aggregates |
2147
- | [`$streams`](#streams-streams) | An async producer, and the value folded **within one run** | Yes — the runtime owns the output | Per chunk; restarts, back to `initial`, when `args` change | Feeds, sockets, continuous observation |
2180
+ | [`$stream`](#streams-stream) | An async producer, and the value folded **within one run** | Yes — the runtime owns the output | Per chunk; restarts, back to `initial`, when `args` change | Feeds, sockets, continuous observation |
2148
2181
  | [`$watch`](#watch-watch) | A reaction to a change | No | Once per batch per changed address, after the scan write | Side effects, "when this becomes true" |
2149
2182
  | [`$scan`](#scan-scan) | An accumulation over time, and what resets it | Yes — the runtime owns the output | Once per landing (`from`) or once per event (`on`) | Paging accumulation, history, counters |
2150
2183
 
2151
2184
  Two rules cut most of the confusion:
2152
2185
 
2153
- - **`$updatedCallback` is not on this list.** It reports the bindings that were applied, so anything hung on it silently depends on what is rendered. See [Demand roots](#demand-roots--what-makes-a-getter-run).
2154
- - **A `$streams` fold resets on every restart; a `$scan` does not.** When the value has to survive the restart, or has to count events rather than states, it belongs in `$scan`.
2186
+ - **`$renderedCallback` is not on this list.** It reports the bindings that were applied, so anything hung on it silently depends on what is rendered. See [Demand roots](#demand-roots--what-makes-a-getter-run).
2187
+ - **A `$stream` fold resets on every restart; a `$scan` does not.** When the value has to survive the restart, or has to count events rather than states, it belongs in `$scan`.
2155
2188
 
2156
- ## Streams (`$streams`)
2189
+ ## Streams (`$stream`)
2157
2190
 
2158
- Command tokens and event tokens carry discrete interactions. **`$streams`** covers the remaining shape: a continuous flow. Declare an async producer (async iterable / async generator / `ReadableStream`) and the framework **folds it into a single reactive property** — each chunk goes through normal path assignment, so bindings, path getters, and `$updatedCallback` react exactly as if you had assigned the value yourself. When a state path read by the `args` function changes, the running producer is aborted and the source is restarted with the new arguments (switchMap-style dependency-driven restart). Streams start eagerly after `$connectedCallback` completes and are aborted when the element disconnects.
2191
+ > **As of 3.2 the canonical declaration key is `$stream`** (requirement B12), singular like `$watch` and `$scan`. The old `$streams` works the same through 3.x and is removed in 4.0. Declaring both fails with `[wcs/declaration-alias]`. The `$streamStatus` / `$streamError` namespaces do not change. On 3.1 or earlier, write `$streams`.
2159
2192
 
2160
- `$updatedCallback` remains binding-driven: a stream declaration alone is not a headless subscription. Its path appears in the callback only when a live DOM binding for that value/status/error is actually applied. To react to a stream's value without rendering it, declare [`$watch`](#watch-watch) on that path; see the [stream reference](docs/streams.md) for the observation contract.
2193
+ Command tokens and event tokens carry discrete interactions. **`$stream`** covers the remaining shape: a continuous flow. Declare an async producer (async iterable / async generator / `ReadableStream`) and the framework **folds it into a single reactive property** — each chunk goes through normal path assignment, so bindings, path getters, and `$renderedCallback` react exactly as if you had assigned the value yourself. When a state path read by the `args` function changes, the running producer is aborted and the source is restarted with the new arguments (switchMap-style dependency-driven restart). Streams start eagerly after `$connectedCallback` completes and are aborted when the element disconnects.
2194
+
2195
+ `$renderedCallback` remains binding-driven: a stream declaration alone is not a headless subscription. Its path appears in the callback only when a live DOM binding for that value/status/error is actually applied. To react to a stream's value without rendering it, declare [`$watch`](#watch-watch) on that path; see the [stream reference](docs/streams.md) for the observation contract.
2161
2196
 
2162
2197
  ```html
2163
2198
  <wcs-state>
@@ -2165,7 +2200,7 @@ Command tokens and event tokens carry discrete interactions. **`$streams`** cove
2165
2200
  export default {
2166
2201
  prompt: "",
2167
2202
 
2168
- $streams: {
2203
+ $stream: {
2169
2204
  // Full form: accumulate an LLM token stream
2170
2205
  tokens: {
2171
2206
  args: (state) => state.prompt, // dependencies are captured here, and only here
@@ -2204,7 +2239,7 @@ On error the property keeps its last folded value and the error lands in `$strea
2204
2239
  **Bridging an event API** — most real sources are callback-shaped (`EventSource`, `WebSocket`, DOM events), not async iterables. Wrap them in a standard `ReadableStream`: enqueue in `start`, release the resource in `cancel`. You never touch the `AbortSignal` — on restart/dispose the runtime cancels its reader, which force-unwinds a parked read and runs your `cancel()`:
2205
2240
 
2206
2241
  ```js
2207
- $streams: {
2242
+ $stream: {
2208
2243
  metrics: {
2209
2244
  args: (state) => ({ host: state.host }),
2210
2245
  source: ({ host }) => {
@@ -2230,6 +2265,7 @@ Key rules:
2230
2265
  - **Bounded fold** — demand never flows back to the producer (backpressure is deliberately abandoned). For infinite / long-lived streams use a bounded fold — latest, count, last-N (`(acc, chunk) => [...acc.slice(-99), chunk]`), windowed aggregates. Raw accumulation of every chunk is for finite streams only.
2231
2266
  - **`args` is synchronous** — returning a Promise is an error, and wildcard reads inside `args` are rejected.
2232
2267
  - **No self-dependency, no mutual cycles** — `args` reading the stream's own value or status raises an error. Mutual cycles between two streams (A's `args` reads B's value and vice versa) are not detected and restart forever — do not build them. One-way chains (A's value feeding B's `args`) are legitimate.
2268
+ - **The runtime owns the property** — an entry name that collides with a getter, a setter or a method declared on the state raises at declaration, the same as a `$scan` output. (Without that check the method would be silently overwritten by the start-time reset to `initial`.)
2233
2269
  - **SSR does not start streams** — on the server the declaration is parsed and the property is materialized with `initial`, but no source runs; the client starts streams as usual.
2234
2270
 
2235
2271
  See [docs/streams.md](docs/streams.md) for the full contract — lifecycle and ownership, restart semantics, flush granularity, and the out-of-scope list.
@@ -2244,9 +2280,9 @@ There are exactly **three** demand roots:
2244
2280
  |---|---|---|
2245
2281
  | **A live DOM binding** | `data-wcs` / mustache / comment bindings | **Yes** — remove the element and the demand goes with it |
2246
2282
  | **A `$watch` declaration** | the state | No (headless) |
2247
- | **A `$streams` `args` function** | the state | No (evaluated on start and on every restart) |
2283
+ | **A `$stream` `args` function** | the state | No (evaluated on start and on every restart) |
2248
2284
 
2249
- **`$updatedCallback` is not a root.** It reports what the bindings did; it does not create demand.
2285
+ **`$renderedCallback` is not a root.** It reports what the bindings did; it does not create demand.
2250
2286
 
2251
2287
  ### Rendering can change program semantics
2252
2288
 
@@ -2258,27 +2294,27 @@ Because the first root lives in the DOM, **an element you think of as display-on
2258
2294
  ```
2259
2295
 
2260
2296
  ```javascript
2261
- // $updatedCallback is binding-driven — delete that <b> and the path stops
2297
+ // $renderedCallback is binding-driven — delete that <b> and the path stops
2262
2298
  // appearing in `paths`, so the feed silently stops committing.
2263
- $updatedCallback(paths) {
2299
+ $renderedCallback(paths) {
2264
2300
  if (!paths.includes("$streamStatus.pageResult")) return;
2265
2301
  this.items = this.items.concat(this.pageResult.items);
2266
2302
  }
2267
2303
  ```
2268
2304
 
2269
- **The rule:** logic that must not depend on what is rendered belongs on a `$watch`, a `$scan`, or a `$streams` `args`. Keep `$updatedCallback` for "follow what was drawn".
2305
+ **The rule:** logic that must not depend on what is rendered belongs on a `$watch`, a `$scan`, or a `$stream` `args`. Keep `$renderedCallback` for "follow what was drawn".
2270
2306
 
2271
- That example now accumulates its feed with `$scan` (and re-arms the sentinel from a `$watch`), and the `<b>` is display-only again. This shape — `$updatedCallback` testing a path that is not bound anywhere — is detected statically as **`wcs/updated-callback-unbound`**.
2307
+ That example now accumulates its feed with `$scan` (and re-arms the sentinel from a `$watch`), and the `<b>` is display-only again. This shape — `$renderedCallback` testing a path that is not bound anywhere — is detected statically as **`wcs/updated-callback-unbound`**.
2272
2308
 
2273
2309
  ### The limitation that remains
2274
2310
 
2275
- Demand still comes from three separate places. **To know whether a getter is evaluated you have to inspect all three — every binding on the page, every `$watch`, and every `$streams` `args` — and reading the getter's definition will not tell you.** The linter and the DevTools wiring-coverage view exist to make a machine do that cross-check.
2311
+ Demand still comes from three separate places. **To know whether a getter is evaluated you have to inspect all three — every binding on the page, every `$watch`, and every `$stream` `args` — and reading the getter's definition will not tell you.** The linter and the DevTools wiring-coverage view exist to make a machine do that cross-check.
2276
2312
 
2277
2313
  Note that a scalar getter named in `$watch` becomes **eager** (evaluated once at connect, then at the end of every batch touching its dependencies). Wildcard row getters do not become eager, because the first evaluation would walk the whole list.
2278
2314
 
2279
2315
  ## Watch (`$watch`)
2280
2316
 
2281
- `$updatedCallback` is **binding-driven**: it reports the paths whose live DOM bindings were actually applied in that update, so a value you never render is invisible to it. **`$watch`** is the headless counterpart — it fires on state changes whether or not anything on the page is bound to the path. (One exception, spelled out below: a *wildcard* row path needs `$listKeys` to work headlessly.)
2317
+ `$renderedCallback` is **binding-driven**: it reports the paths whose live DOM bindings were actually applied in that update, so a value you never render is invisible to it. **`$watch`** is the headless counterpart — it fires on state changes whether or not anything on the page is bound to the path. (One exception, spelled out below: a *wildcard* row path needs `$listKeys` to work headlessly.)
2282
2318
 
2283
2319
  ```html
2284
2320
  <wcs-state>
@@ -2323,11 +2359,11 @@ Firing order is defined in three layers, and only the middle one is yours to ste
2323
2359
 
2324
2360
  | Layer | Order | Your control |
2325
2361
  |---|---|---|
2326
- | Mechanisms | `$updatedCallback` → `$scan` → `$watch` → `$streams` restart | fixed |
2362
+ | Mechanisms | `$renderedCallback` → `$scan` → `$watch` → `$stream` restart | fixed |
2327
2363
  | Between handlers | declaration order in `$watch` | **reorder the declarations** |
2328
2364
  | Between rows of one path | ascending `indexes` | fixed |
2329
2365
 
2330
- **The one thing that moves the mechanism layer** is a `<wcs-view-transition>` that accepts the `state` participant. Binding application — and with it `$updatedCallback` — then lands on a frame, while `$scan`, `$watch` and the `$streams` restart stay on the microtask the drain was queued on, because they consume state addresses and not the DOM. For as long as the tag is present the order is `$scan` → `$watch` → `$streams` restart → `$updatedCallback`. Nothing else on the page reorders this layer; see [docs/timing-and-firing-contract.md](https://github.com/wcstack/wcstack/blob/main/docs/timing-and-firing-contract.md) §4.3.
2366
+ **The one thing that moves the mechanism layer** is a `<wcs-view-transition>` that accepts the `state` participant. Binding application — and with it `$renderedCallback` — then lands on a frame, while `$scan`, `$watch` and the `$stream` restart stay on the microtask the drain was queued on, because they consume state addresses and not the DOM. For as long as the tag is present the order is `$scan` → `$watch` → `$stream` restart → `$renderedCallback`. Nothing else on the page reorders this layer; see [docs/timing-and-firing-contract.md](https://github.com/wcstack/wcstack/blob/main/docs/timing-and-firing-contract.md) §4.3.
2331
2367
 
2332
2368
  Key rules:
2333
2369
 
@@ -2336,14 +2372,14 @@ Key rules:
2336
2372
  - **Rows follow the list as it stands at the drain** — a row written and then removed, replaced or cut off in the same job does not fire, a row that only moved into another position does not fire, and each position fires at most once. Replacing a nested list fires for every row of the new array.
2337
2373
  - **Row-level diffs want `$listKeys`** — without it, assigning a whole array fires the row watch for *every* row with `prev === undefined`, because no row went through a path write. With `$listKeys` declared, the key match decomposes the assignment into per-field writes, so only changed rows fire and `prev` is a real scalar.
2338
2374
  - **A headless row watch requires `$listKeys`** — this is the one place `$watch` is *not* headless on its own. Expanding `items` into `items.*.price` is driven by the list's `for` binding, and declaring a watch deliberately does not register the path as a list. So with neither a `for` binding nor `$listKeys`, assigning the array fires the row watch **zero** times. Add `$listKeys` (the key match writes each field by path, bypassing the expansion) or render the list. Scalar paths — including nested ones like `user.name` — are headless with no such condition.
2339
- - **Handler exceptions are isolated** — a throw is reported to the console and the remaining watches (and stream restarts) still run. This differs from `$connectedCallback` / `$updatedCallback`, which fail loudly.
2375
+ - **Handler exceptions are isolated** — a throw is reported to the console and the remaining watches (and stream restarts) still run. This differs from `$connectedCallback` / `$renderedCallback`, which fail loudly.
2340
2376
  - **Write chains are bounded** — a handler's writes form a new batch, so mutually-writing watches would loop forever; the chain is cut off after 32 links with a console error. Values and DOM are not rolled back.
2341
- - **Not run on a mounted `bind-component` scope** — mounted components do not execute declaration surfaces: the `$watch` declaration is ignored with a one-time console warning that points to the root state (or a volume — `<wcs-state mount>` hosts `$watch` / `$listKeys` / `$updatedCallback`). This applies to `$streams` too. A plain (unwired Shadow) child owns an independent tree and can declare it.
2377
+ - **Not run on a mounted `bind-component` scope** — mounted components do not execute declaration surfaces: the `$watch` declaration is ignored with a one-time console warning that points to the root state (or a volume — `<wcs-state mount>` hosts `$watch` / `$listKeys` / `$renderedCallback`). This applies to `$stream` too. A plain (unwired Shadow) child owns an independent tree and can declare it.
2342
2378
  - **SSR does not run watches** — handler side effects would otherwise execute on both server and client.
2343
2379
 
2344
2380
  ## Scan (`$scan`)
2345
2381
 
2346
- `$streams` folds *within* one run — every restart resets the value to `initial` — and `$watch` owns no value. **`$scan`** declares the value that has to outlive both: an accumulation over time, with an owner, a firing unit and a reset condition.
2382
+ `$stream` folds *within* one run — every restart resets the value to `initial` — and `$watch` owns no value. **`$scan`** declares the value that has to outlive both: an accumulation over time, with an owner, a firing unit and a reset condition.
2347
2383
 
2348
2384
  ```html
2349
2385
  <wcs-state>
@@ -2352,7 +2388,7 @@ Key rules:
2352
2388
  page: 1,
2353
2389
  host: "a",
2354
2390
  $eventTokens: ["message"],
2355
- $streams: {
2391
+ $stream: {
2356
2392
  pageResult: { args: (s) => s.page, source: loadPage },
2357
2393
  },
2358
2394
  $scan: {
@@ -2388,7 +2424,7 @@ Key rules:
2388
2424
  | `fold` | Required. `from`: `(acc, cur, prev, ...indexes) => next`. `on`: `(acc, event, ...indexes) => next`. Synchronous, called without `this`, returns a new value. Returning `acc` itself writes nothing. |
2389
2425
  | `resetOn` | Optional array of plain state paths. When one of them is written, the output returns to `initial`: a `from` scan skips that batch's fold, and an `on` scan folds any event that comes after the write into `initial`. A path under `from` raises; an ancestor of `from` is allowed (start over when the parent is replaced). An object path resets only when that object itself is written, not on writes to its children — list the leaf paths or use a nonce. |
2390
2426
 
2391
- **The runtime owns the output**, like a `$streams` value. It is materialized from `initial` when the state does not already have that property (plain data is copied, so writing a child path in the plain part of the output never changes the declared `initial`; class instances, frozen values and other non-plain values stay shared with it), and you bind it like any other path. It survives stream restarts, disconnect and reconnect, and a re-set of the same object; a re-set with a new declaration rebuilds the scan. An output name that collides with a getter, a setter, a method or a `$streams` entry raises.
2427
+ **The runtime owns the output**, like a `$stream` value. It is materialized from `initial` when the state does not already have that property (plain data is copied, so writing a child path in the plain part of the output never changes the declared `initial`; class instances, frozen values and other non-plain values stay shared with it), and you bind it like any other path. It survives stream restarts, disconnect and reconnect, and a re-set of the same object; a re-set with a new declaration rebuilds the scan. An output name that collides with a getter, a setter, a method or a `$stream` entry raises.
2392
2428
 
2393
2429
  How the two sources fire:
2394
2430
 
@@ -2403,8 +2439,8 @@ Key rules:
2403
2439
  - **Never fold a getter.** A getter re-evaluates whenever its inputs change, so a fold over it would count re-evaluations, not events. A getter as `from` or `resetOn` — or an expansion of a `$recursion` `**` getter such as `nodes.*.total` as `from` — raises at declaration (`wcs/scan-source-computed`).
2404
2440
  - **One fold per landing, not per page.** A retry after the page is `done`, or reconnecting the page, lands the same page again. When that matters, keep an idempotency key in the fold — the `pages` list above.
2405
2441
  - **Do not derive a stream's `args` from its own scan output.** A getter over `feed` — or over another scan that folds `feed` — read by `pageResult`'s `args` would restart the stream on its own result, so the runtime raises `wcs/scan-feedback-loop`. Advance the cursor from an event instead. A chunk that lands in the same batch as its stream's restart belongs to the aborted run and is not folded.
2406
- - **Receive element events through `on`.** A `from` path sees every write to that path, including a bound element's initial sync and a whole-parent write (which arrives with `prev === undefined`). `prev` follows `$watch`'s ledger, so it is also `undefined` for a write made inside the `$scan` / `$watch` listener — by a `$watch` handler, or by another scan whose output is the `from`. The ledger is cleared at the end of that listener, so the `$streams` restart that runs after it in the same drain keeps `prev`.
2407
- - **Keep folds bounded.** An infinite source must fold into a bounded value (the last N, a count), exactly as with `$streams`.
2442
+ - **Receive element events through `on`.** A `from` path sees every write to that path, including a bound element's initial sync and a whole-parent write (which arrives with `prev === undefined`). `prev` follows `$watch`'s ledger, so it is also `undefined` for a write made inside the `$scan` / `$watch` listener — by a `$watch` handler, or by another scan whose output is the `from`. The ledger is cleared at the end of that listener, so the `$stream` restart that runs after it in the same drain keeps `prev`.
2443
+ - **Keep folds bounded.** An infinite source must fold into a bounded value (the last N, a count), exactly as with `$stream`.
2408
2444
  - **Errors are isolated.** A throw, a returned Promise or a value that cannot be read is reported to the console and DevTools and writes nothing (an unreadable row of a wildcard `from` is skipped alone, and row landings are narrowed to one per list position); the other scans, watches and stream restarts still run.
2409
2445
  - **`$watch` runs after the scan write.** A `$watch` handler in the same drain reads the output as folded, and a value it writes to the output stays. When the `from` source is written again before the output's landing drains — by a `$watch` handler in that drain, say — both land in one batch: a `$watch` on the output then gets the landed value in `prev`, sees `cur` one step ahead, and can fire again with the same value in the next batch, so make it tolerant of a repeated value. Clear an accumulation from a user action with a nonce read by `resetOn`.
2410
2446
  - **Root only.** A volume (`mount=`) refuses `$scan`, and a mounted `bind-component` scope ignores it with a one-time warning. Under SSR, `from` does not fold; the output is still materialized.
@@ -2546,7 +2582,7 @@ Both declarations are validated when the component is defined, with the same str
2546
2582
  - an entry that is not a non-empty string
2547
2583
  - an entry starting with `$` (internal properties are never exposed on the component prototype)
2548
2584
  - a duplicated entry — this one used to fail silently: a duplicate name makes the whole `wcBindable` declaration unreadable, so the element would quietly stop being two-way bindable
2549
- - an entry that does not exist on the state (own properties and the prototype chain are both searched; `$streams` names count as existing since their value properties are materialized per instance)
2585
+ - an entry that does not exist on the state (own properties and the prototype chain are both searched; `$stream` names count as existing since their value properties are materialized per instance)
2550
2586
  - a method listed in `$bindables`, or a value property listed in `$commands`
2551
2587
 
2552
2588
  ### Driving a DCC Method
@@ -2606,7 +2642,7 @@ Properties prefixed with `$` are internal and not exposed on the component proto
2606
2642
  | `$commands` | Declares invocable methods |
2607
2643
  | `$connectedCallback` | Lifecycle hook (runs on each instance) |
2608
2644
  | `$disconnectedCallback` | Cleanup hook |
2609
- | `$updatedCallback` | Called after state mutations |
2645
+ | `$renderedCallback` | Called after state mutations |
2610
2646
 
2611
2647
  ## SVG Support
2612
2648
 
@@ -2622,7 +2658,9 @@ All bindings work inside `<svg>` elements. Use `attr.*` for SVG attributes:
2622
2658
 
2623
2659
  ## Lifecycle Hooks
2624
2660
 
2625
- State objects can define `$connectedCallback`, `$disconnectedCallback`, `$updatedCallback`, and `$errorCallback` for initialization, cleanup, update, and binding-failure handling.
2661
+ State objects can define `$connectedCallback`, `$disconnectedCallback`, `$renderedCallback`, and `$errorCallback` for initialization, cleanup, update, and binding-failure handling.
2662
+
2663
+ > **As of 3.2 the canonical name of `$updatedCallback` is `$renderedCallback`** (requirement B12). The hook receives updates of **applied bindings**, not every state update (use `$watch` for those), and the new name says so. The old name works the same through 3.x and is removed in 4.0. Declaring both fails with `[wcs/declaration-alias]`. On 3.1 or earlier, write `$updatedCallback`.
2626
2664
 
2627
2665
  ```html
2628
2666
  <wcs-state>
@@ -2651,15 +2689,15 @@ State objects can define `$connectedCallback`, `$disconnectedCallback`, `$update
2651
2689
  |---|---|---|
2652
2690
  | `$connectedCallback` | After state initialization on first connect; on every reconnect thereafter | Yes (awaited) |
2653
2691
  | `$disconnectedCallback` | When the element is removed from the DOM | No (sync only) |
2654
- | `$updatedCallback(paths, indexesListByPath)` | After updates are applied to live bindings | Yes (not awaited) |
2655
- | `$errorCallback(error, info)` | After a drain in which a binding failed to apply — once per failed binding, after `$updatedCallback` | Yes (not awaited) |
2692
+ | `$renderedCallback(paths, indexesListByPath)` | After updates are applied to live bindings | Yes (not awaited) |
2693
+ | `$errorCallback(error, info)` | After a drain in which a binding failed to apply — once per failed binding, after `$renderedCallback` | Yes (not awaited) |
2656
2694
 
2657
2695
  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.
2658
2696
 
2659
2697
  - `this` inside hooks is the state proxy with full read/write access
2660
2698
  - `$connectedCallback` is called **every time** the element is connected (including re-insertion after removal), making it suitable for setup that should be re-established
2661
2699
  - `$disconnectedCallback` is called synchronously — use it for cleanup such as clearing timers, removing event listeners, or releasing resources
2662
- - `$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
2700
+ - `$renderedCallback(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
2663
2701
  - `$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:
2664
2702
 
2665
2703
  ```js
@@ -2672,7 +2710,7 @@ All hooks except `$disconnectedCallback` support `async` — you can use `async/
2672
2710
  };
2673
2711
  ```
2674
2712
 
2675
- 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).
2713
+ The hook runs after the batch (after `$renderedCallback`), 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` / `$renderedCallback` (those fail loudly).
2676
2714
  - In Web Components, define `async $stateReadyCallback(stateProp)` to receive a hook when the bound state becomes available via `bind-component`
2677
2715
 
2678
2716
  ## Transition animations
@@ -2695,8 +2733,8 @@ li {
2695
2733
 
2696
2734
  Two consequences to know while that tag accepts the `state` participant:
2697
2735
 
2698
- - The drain lands on a frame instead of a microtask, so code that writes state and then reads the DOM after `await Promise.resolve()` must wait for the transition. `$updatedCallback` still fires immediately after the bindings are applied — its *position* is unchanged, but it moves a frame later along with them.
2699
- - Because `$scan`, `$watch` and the `$streams` restart stay on the original microtask, they now run **before** `$updatedCallback` instead of after it.
2736
+ - The drain lands on a frame instead of a microtask, so code that writes state and then reads the DOM after `await Promise.resolve()` must wait for the transition. `$renderedCallback` still fires immediately after the bindings are applied — its *position* is unchanged, but it moves a frame later along with them.
2737
+ - Because `$scan`, `$watch` and the `$stream` restart stay on the original microtask, they now run **before** `$renderedCallback` instead of after it.
2700
2738
 
2701
2739
  Only a batch that actually has bindings to apply is handed to the tag, so a write to a headless path never starts a transition. Without the tag the drain is exactly what it was. See [docs/timing-and-firing-contract.md](https://github.com/wcstack/wcstack/blob/main/docs/timing-and-firing-contract.md) §4.3.
2702
2740
 
@@ -2740,7 +2778,7 @@ Anything that follows mechanically from the path string is reported at runtime a
2740
2778
  | `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 |
2741
2779
  | `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 |
2742
2780
  | `wcs/index-param-range` | `$N` must name an existing wildcard level: `$1` through `$128`, no leading zeros | Use a level that exists |
2743
- | `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 |
2781
+ | `wcs/recursion-unsupported` | `**` reached something that does not interpret it — markup, a `$watch` or `$listKeys` key, `$resolve` / `$postUpdate` / `$dependOn`, an assignment — or the state declares no `$recursion` at all | Use a concrete path, or declare the anchor |
2744
2782
  | `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 |
2745
2783
  | `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 |
2746
2784
  | `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 |
@@ -2766,7 +2804,7 @@ this.$getAll("matrix.*.*", [row]);
2766
2804
 
2767
2805
  ### One failing binding is confined to that binding
2768
2806
 
2769
- If applying a binding throws, the rest of that batch, `$updatedCallback`, `$watch`, and `$streams` restarts all still run. The failure is not swallowed — it goes to `console.error` and to DevTools (`state:binding-apply-error`):
2807
+ If applying a binding throws, the rest of that batch, `$renderedCallback`, `$watch`, and `$stream` restarts all still run. The failure is not swallowed — it goes to `console.error` and to DevTools (`state:binding-apply-error`):
2770
2808
 
2771
2809
  ```
2772
2810
  [@wcstack/state] binding "text: items.*.label" failed to apply; the rest of this batch continues.
@@ -3064,7 +3102,7 @@ Subpath entries for tooling: `@wcstack/state/parser` (the `data-wcs` parser as a
3064
3102
  | `setterPaths` | Set of paths defined as setters |
3065
3103
  | `createState(mutability, callback)` | Create a state proxy (`"readonly"` or `"writable"`) |
3066
3104
  | `createStateAsync(mutability, callback)` | Async version of `createState` |
3067
- | `setInitialState(state)` | Set state programmatically. Before initialization it supplies the initial state. On an initialized element it replaces the whole state and re-applies every established binding to the new state before it returns (a detached element re-applies them when it reconnects); a binding whose path the new state no longer has reports a failed apply instead of keeping the old text. A re-set is not a write: it fires no `$watch` handler and no `$updatedCallback`. Lists are matched by array identity, so pass a new array when a list's length changed — re-setting with the same array instance after pushing to or splicing it in place is not supported. Throws on a loaded volume (`<wcs-state mount="…">` — its data was copied into the root tree, so write the paths under the mount path on the root instead), on a tree with grafted volumes or mounted components, and on an element that already failed to initialize — such an element cannot be re-armed; remove it and create a new one |
3105
+ | `setInitialState(state)` | Set state programmatically. Before initialization it supplies the initial state. On an initialized element it replaces the whole state and re-applies every established binding to the new state before it returns (a detached element re-applies them when it reconnects); a binding whose path the new state no longer has reports a failed apply instead of keeping the old text. A re-set is not a write: it fires no `$watch` handler and no `$renderedCallback`. Lists are matched by array identity, so pass a new array when a list's length changed — re-setting with the same array instance after pushing to or splicing it in place is not supported. Throws on a loaded volume (`<wcs-state mount="…">` — its data was copied into the root tree, so write the paths under the mount path on the root instead), on a tree with grafted volumes or mounted components, and on an element that already failed to initialize — such an element cannot be re-armed; remove it and create a new one |
3068
3106
  | `nextVersion()` | Increment and return version number |
3069
3107
 
3070
3108
  ## Architecture