@wcstack/state 3.5.4 โ†’ 4.0.0-rc.2

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 (82) hide show
  1. package/README.ja.md +669 -428
  2. package/README.md +667 -426
  3. package/dist/auto.min.js +1 -2
  4. package/dist/define.d.ts +4 -8
  5. package/dist/define.js +1 -1
  6. package/dist/index.d.ts +139 -1483
  7. package/dist/index.esm.js +1 -2
  8. package/dist/manifest.d.ts +18 -59
  9. package/dist/manifest.esm.js +542 -1202
  10. package/dist/parser.d.ts +49 -103
  11. package/dist/parser.esm.js +662 -831
  12. package/dist/split/auto.js +1 -0
  13. package/dist/split/chunks/chunk-732CB4QN.js +1 -0
  14. package/dist/split/chunks/chunk-7PN5YNZZ.js +1 -0
  15. package/dist/split/chunks/chunk-FF6KHTYS.js +1 -0
  16. package/dist/split/chunks/chunk-HFKGUDDJ.js +1 -0
  17. package/dist/split/chunks/chunk-PKDBVBAP.js +1 -0
  18. package/dist/split/chunks/chunk-QI7P7YPP.js +1 -0
  19. package/dist/split/chunks/chunk-QQH6N5CJ.js +1 -0
  20. package/dist/split/chunks/chunk-UM3O6QXD.js +1 -0
  21. package/dist/split/chunks/chunk-WEL3YSG7.js +1 -0
  22. package/dist/split/chunks/chunk-WWJNAZVP.js +1 -0
  23. package/dist/split/chunks/chunk-YOPPVWPQ.js +1 -0
  24. package/dist/split/core.d.ts +114 -1168
  25. package/dist/split/core.js +1 -2
  26. package/dist/split/features/devtools.d.ts +23 -3
  27. package/dist/split/features/devtools.js +1 -2
  28. package/dist/split/features/diagnostics.d.ts +27 -5
  29. package/dist/split/features/diagnostics.js +1 -2
  30. package/dist/split/features/formats.d.ts +21 -5
  31. package/dist/split/features/formats.js +1 -2
  32. package/dist/split/features/list-keys.d.ts +24 -0
  33. package/dist/split/features/list-keys.js +1 -0
  34. package/dist/split/features/recursion.d.ts +23 -3
  35. package/dist/split/features/recursion.js +1 -2
  36. package/dist/split/features/scopes.d.ts +25 -4
  37. package/dist/split/features/scopes.js +1 -2
  38. package/dist/split/features/ssr.d.ts +23 -3
  39. package/dist/split/features/ssr.js +1 -2
  40. package/dist/split/features/temporal.d.ts +19 -4
  41. package/dist/split/features/temporal.js +1 -2
  42. package/dist/wcs-manifest.json +43 -49
  43. package/package.json +15 -17
  44. package/dist/auto.min.js.map +0 -1
  45. package/dist/index.esm.js.map +0 -1
  46. package/dist/split/chunks/binder.js +0 -2
  47. package/dist/split/chunks/binder.js.map +0 -1
  48. package/dist/split/chunks/checkDependency.js +0 -2
  49. package/dist/split/chunks/checkDependency.js.map +0 -1
  50. package/dist/split/chunks/declarationAliases.js +0 -2
  51. package/dist/split/chunks/declarationAliases.js.map +0 -1
  52. package/dist/split/chunks/declarationHooks.js +0 -2
  53. package/dist/split/chunks/declarationHooks.js.map +0 -1
  54. package/dist/split/chunks/diagnosticsHooks.js +0 -2
  55. package/dist/split/chunks/diagnosticsHooks.js.map +0 -1
  56. package/dist/split/chunks/errorGuidance.js +0 -2
  57. package/dist/split/chunks/errorGuidance.js.map +0 -1
  58. package/dist/split/chunks/featureBridge.js +0 -2
  59. package/dist/split/chunks/featureBridge.js.map +0 -1
  60. package/dist/split/chunks/features.d.ts +0 -29
  61. package/dist/split/chunks/filterRegistry.js +0 -2
  62. package/dist/split/chunks/filterRegistry.js.map +0 -1
  63. package/dist/split/chunks/getAllPropertyDescriptors.js +0 -2
  64. package/dist/split/chunks/getAllPropertyDescriptors.js.map +0 -1
  65. package/dist/split/chunks/lifecycleHooks.js +0 -2
  66. package/dist/split/chunks/lifecycleHooks.js.map +0 -1
  67. package/dist/split/chunks/pathDiagnostics.js +0 -2
  68. package/dist/split/chunks/pathDiagnostics.js.map +0 -1
  69. package/dist/split/chunks/registerComponents.js +0 -2
  70. package/dist/split/chunks/registerComponents.js.map +0 -1
  71. package/dist/split/chunks/setAll.js +0 -2
  72. package/dist/split/chunks/setAll.js.map +0 -1
  73. package/dist/split/chunks/version.js +0 -2
  74. package/dist/split/chunks/version.js.map +0 -1
  75. package/dist/split/core.js.map +0 -1
  76. package/dist/split/features/devtools.js.map +0 -1
  77. package/dist/split/features/diagnostics.js.map +0 -1
  78. package/dist/split/features/formats.js.map +0 -1
  79. package/dist/split/features/recursion.js.map +0 -1
  80. package/dist/split/features/scopes.js.map +0 -1
  81. package/dist/split/features/ssr.js.map +0 -1
  82. package/dist/split/features/temporal.js.map +0 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > ๐Ÿค– **AI coding agents**: This README is a package-level reference, not the primary entry point for building a wcstack application. If you have not already done so, first read the repository [README](https://github.com/wcstack/wcstack#readme) and [AGENTS.md](https://github.com/wcstack/wcstack/blob/main/AGENTS.md), then use the [wcstack-app skill](https://github.com/wcstack/wcstack-skill).
4
4
 
5
- > **Preparing for 4.0**: 4.0 is not released yet. What changes, and what you can already do on 3.x, is in the [3.x โ†’ 4.0 migration guide (preview)](https://github.com/wcstack/wcstack/blob/main/docs/migration-v4.md).
5
+ > **This README describes 4.0.** Coming from 3.x? Read [Upgrading from 3.x](#upgrading-from-3x) and the [3.x โ†’ 4.0 migration guide](https://github.com/wcstack/wcstack/blob/main/docs/migration-v4.md).
6
6
 
7
7
  **This is not another convenient frontend framework. It brings a lineage established outside frontend development โ€” the one where a path string is the contract between view and model โ€” onto web standards.**
8
8
 
@@ -43,7 +43,7 @@ The nearer relatives are the **attribute-directive, no-build libraries** โ€” Alp
43
43
 
44
44
  **Choose it** for HTML-first pages: server-rendered or static markup with reactive parts, a page composed from custom elements, anywhere "read the HTML and know every data dependency" matters and a build step is a cost rather than a given.
45
45
 
46
- **Do not choose it** when the team already lives inside a component framework โ€” use the I/O nodes through the [framework adapters](../../docs/framework-adapter-integration.md) instead; when the hot path is a very large keyed list โ€” [Performance](#performance) measures create / append at 2.5โ€“3.5ร— [`@wcstack/signals`](../signals/), which interoperates with this package and is the better fit there; when templates need inline expressions โ€” deliberately absent; or when the template must be type-checked by the compiler rather than by tooling โ€” paths are strings, and `@wcstack/typescript` narrows that gap without closing it.
46
+ **Do not choose it** when the team already lives inside a component framework โ€” use the I/O nodes through the [framework adapters](../../docs/framework-adapter-integration.md) instead; when the hot path is a very large keyed list and create / append time decides โ€” measure it ([Performance](#performance)), and consider [`@wcstack/signals`](../signals/), which interoperates with this package; when templates need inline expressions โ€” deliberately absent; or when the template must be type-checked by the compiler rather than by tooling โ€” paths are strings, and `@wcstack/typescript` narrows that gap without closing it.
47
47
 
48
48
  On those axes the comparison is concrete: the [Performance](#performance) section below is one, and the drivers under `e2e/bench/` regenerate it on your own hardware.
49
49
 
@@ -56,7 +56,7 @@ The premise โ€” *a path string is the whole contract between a view and a model*
56
56
  | **Spreadsheets** (VisiCalc, 1979) | An address, a formula declaring what a cell **is**, a dependency graph, lazy recomputation โ€” and no update code anywhere | Names instead of grid coordinates, and one formula per *shape* rather than per cell: `get "cart.items.*.subtotal"()` is not filled down into the rows; the wildcard is the definition |
57
57
  | **Cocoa Bindings / KVCโ€“KVO** (NeXT's EOF, 1994; Mac OS X 10.3, 2003) | Key paths (`person.address.street`), a binding triple of target + key path + value transformer, and collection operators that aggregate along a path (`@sum.items.price`) | The triple lives in the markup instead of a nib or a `bind:toObject:withKeyPath:` call, so it can be grepped, linted and diffed. Change detection is an ES Proxy over plain objects rather than KVC compliance |
58
58
  | **XForms** (W3C Recommendation, 2003) | Model / instance / view separation, `ref` paths into the instance, and `<bind calculate="โ€ฆ">` โ€” a computed value declared **at a path**, the direct ancestor of a path getter | The path is an address and nothing else: the computation is a JavaScript getter on the state, not XPath inside an attribute. And it runs in a stock browser, with no XForms processor |
59
- | **WPF / XAML** (2006) | `{Binding Path=User.Name, Mode=TwoWay}`; a `DataContext` that re-roots a whole subtree; `UpdateSourceTrigger` choosing when the source is written; `IValueConverter` between the ends | One state tree per root, rather than a context inherited down the visual tree with `RelativeSource` / `ElementName` escapes โ€” `state: user` is that re-rooting, written in the host's HTML. Converters are a closed set of 46 filters, not classes you register, and nothing is compiled |
59
+ | **WPF / XAML** (2006) | `{Binding Path=User.Name, Mode=TwoWay}`; a `DataContext` that re-roots a whole subtree; `UpdateSourceTrigger` choosing when the source is written; `IValueConverter` between the ends | One state tree per root, rather than a context inherited down the visual tree with `RelativeSource` / `ElementName` escapes โ€” `state: user` is that re-rooting, written in the host's HTML. Converters are a closed set of 47 filters, not classes you register, and nothing is compiled |
60
60
  | **Android Data Binding** (2015) | The path in the layout file itself โ€” `android:text="@{user.name}"`, `@={}` for two-way | No build step and no generated binding class, and no expressions inside the attribute |
61
61
  | **SCADA / HMI tag binding** (industrial, decades) | Widget properties wired to tag paths (`Line1/Tank/Level`) by configuration alone, and *indirect* bindings that parameterize the path (`Folder/Tag_{1}`) so one screen drives many devices | The tree carries derived values, lists and mounted components, not a flat namespace of scalars; the parameter is a loop's wildcard, resolved by the row the binding sits in rather than assigned from a dropdown |
62
62
 
@@ -90,7 +90,7 @@ This separates UI and state with **no JavaScript intermediary**. You can:
90
90
 
91
91
  - 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
92
92
  - Refactor state structure and only update path strings
93
- - 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
93
+ - Read the HTML and know every binding; the dependencies that are not in the HTML (`$watch`, `$stream`) are all declared in one place, the state
94
94
 
95
95
  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.
96
96
 
@@ -124,12 +124,13 @@ Every row is a section of this README. Unless it appears under [Where the neighb
124
124
 
125
125
  | Area | In one line | Reference |
126
126
  |---|---|---|
127
- | **Path model** | Dot paths address state; `*` is an abstract index, `**` a depth, `$1` / `$2` name the axes | [First principle](#first-principle-path-as-the-universal-contract) ยท [Loop index variables](#loop-index-variables-1-2) |
128
- | **Binding syntax** | One `data-wcs` attribute carries property / text / class / style / attribute / event bindings; `{{ }}` in text nodes; the same inside `<svg>` | [Binding syntax](#binding-syntax) ยท [Mustache](#mustache-syntax) ยท [SVG](#svg-support) |
127
+ | **Path model** | Dot paths address state; `*` is an abstract index, `**` a depth, `$1` / `$2` name the axes | [First principle](#first-principle-path-as-the-universal-contract) ยท [Loop index variables](#loop-index-variables-1-2-) |
128
+ | **Binding syntax** | One `data-wcs` attribute carries property / text / class / style / attribute / event bindings; `{{ }}` and `<!--@@: path-->` in text; the same inside `<svg>` | [Binding syntax](#binding-syntax) ยท [Mustache](#mustache-syntax) ยท [SVG](#svg-support) |
129
+ | **Events** | `on*:` handlers of the common bubbling events are delegated to the root; `#direct` attaches one to its element | [Event handling](#event-handling) |
129
130
  | **Structural directives** | `for` and `if` / `elseif` / `else` on `<template>` elements | [Structural directives](#structural-directives) |
130
131
  | **Row identity** | Rows diff by reference, so a sort or a filter reuses the DOM; `$listKeys` keeps row DOM and row objects across refetched arrays | [`$listKeys`](#listkeys--identity-for-refetched-rows) |
131
132
  | **Forms** | Two-way binding for `input` / `select` / `textarea`, a radio group to one value, a checkbox group to an array, `#ro` / `#onchange` / `#prevent` / `#stop` | [Two-way binding](#two-way-binding) ยท [Modifiers](#modifiers) |
132
- | **Filters** | 46 built-ins, chainable, locale-aware formatting that reads `<html lang>` | [Filters](#filters) ยท [Locale](#locale) |
133
+ | **Filters** | 47 built-ins, chainable, locale-aware formatting that reads `<html lang>` | [Filters](#filters) ยท [Locale](#locale) |
133
134
  | **Derived state** | Path getters declare virtual properties at any depth from one flat place; they chain, and they take setters | [Path getters](#path-getters-computed-properties) |
134
135
  | **Aggregation and bulk write** | `$getAll` / `$setAll` / `$resolve` read and write across `items.*.price` without rebuilding the array | [Proxy APIs](#proxy-apis) |
135
136
  | **Recursive paths** | `$recursion` declares where a tree's shape repeats; one `**` getter covers every depth | [Recursive paths](#recursive-paths-recursion) |
@@ -137,12 +138,14 @@ Every row is a section of this README. Unless it appears under [Where the neighb
137
138
  | **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) |
138
139
  | **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) |
139
140
  | **Components** | Two mutually exclusive mechanisms: a JavaScript class with `bind-component`, or HTML-only DCC | [Choosing a mechanism](#choosing-a-component-mechanism) |
140
- | **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) |
141
+ | **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) |
141
142
  | **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) |
142
- | **Time** | `$stream` folds an async source, `$watch` reacts headlessly, `$scan` owns an accumulation that outlives both (`$scan` warns in 3.5 and is removed in 4.0 โ€” [Preparing for 4.0](#preparing-for-40-wcsv4-migration)) | [Choosing a time mechanism](#choosing-a-time-mechanism) |
143
+ | **Time** | `$stream` folds an async source, `$watch` reacts headlessly | [Choosing a time mechanism](#choosing-a-time-mechanism) |
143
144
  | **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) |
144
- | **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) |
145
- | **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) |
145
+ | **Configuration** | `bootstrapState()` sets how the page spells the markup; each state declares its own behavior (`$behavior`) and the add-ons it needs (`$features`) | [Configuration](#configuration) |
146
+ | **Diagnostics** | Unresolved paths, index arity, wildcard rank and getter cycles are reported, under the lint's codes and a message number; one failing binding stays confined, and neither values nor the DOM are rolled back | [Diagnostics](#diagnostics-and-failure-handling) |
147
+ | **Security** | Values are never sanitized and never read as markup; markup you did not write must not reach the page walk | [Security](#security) |
148
+ | **Delivery** | Zero runtime dependencies, no build step, ESM, one CDN `/auto` tag, or split entries with only the add-ons a page uses; no `unsafe-eval`, Trusted Types supported | [Installation](#installation) ยท [docs/csp.md](../../docs/csp.md) |
146
149
 
147
150
  ### Where the neighbours come in
148
151
 
@@ -154,12 +157,12 @@ Every row is a section of this README. Unless it appears under [Where the neighb
154
157
  | [`@wcstack/lint`](../lint/) | `npx @wcstack/lint <file>` checks every `data-wcs` in an HTML file before it runs | The diagnostic codes and `getWcsManifest()`, both derived from this implementation โ€” [Diagnostics](#diagnostics-and-failure-handling) |
155
158
  | VS Code extension (`wcstack-intellisense`) | The same diagnostics, plus completion, inside the editor | The same manifest and codes |
156
159
  | [`@wcstack/typescript`](../typescript/) | `wcs-schema` carries the types into the HTML validator; `wcs-tsc` type-checks inline state scripts | `defineState()`, `WcsPaths<T>` / `WcsPathValue<T, P>` โ€” [TypeScript support](#typescript-support) |
157
- | [`@wcstack/devtools`](../devtools/) | A browser panel over state, wiring and update history | The instrumentation the panel reads |
160
+ | [`@wcstack/devtools`](../devtools/) | A browser panel over state, wiring and update history | The instrumentation the panel reads (hook protocol v2; the `devtools` add-on, included in the full entries) |
158
161
  | [`@wcstack/testing`](../testing/) | `mount()` / `settle()` / `fire()` as one import | The bare recipes that need no extra package โ€” [Testing your page](#testing-your-page) |
159
162
  | [`@wcstack/view-transition`](../view-transition/) | Animates list moves, removals and branch swaps through the View Transition API | The transition-runner hand-off; with no arbiter on the page the mutation applies directly โ€” [Transition animations](#transition-animations) |
160
- | [`@wcstack/router`](../router/) ยท [`@wcstack/autoloader`](../autoloader/) | Declarative routing; automatic loading of undefined custom elements | Paths a route can write into, and bindings that wait for a late definition |
161
- | The [I/O nodes](../../README.md#additional-packages) โ€” `fetch`, `storage`, `ws`, `midi`, โ€ฆ | The platform APIs as elements | The wc-bindable wiring, spread and the token protocols that connect them โ€” [Spread](#spread-binding) |
162
- | [`@wcstack/signals`](../signals/) | A different reactive core, 2.5โ€“3.5ร— faster on create / append for very large keyed lists | Interop โ€” both speak wc-bindable, so the I/O nodes and DCCs are shared โ€” [Performance](#performance) |
163
+ | [`@wcstack/router`](../router/) ยท [`@wcstack/autoloader`](../autoloader/) | Declarative routing; automatic loading of undefined custom elements | Paths a route can write into, the binder that binds route content when it is inserted ([Structural templates in inserted content](#structural-templates-in-inserted-content)), and bindings that wait for a late definition |
164
+ | The [I/O nodes](../../README.md#additional-packages) โ€” `fetch`, `storage`, `ws`, `midi`, โ€ฆ | The platform APIs as elements | The wc-bindable wiring, spread and the token protocols that connect them โ€” [Spread](#spread-binding-) |
165
+ | [`@wcstack/signals`](../signals/) | A different reactive core: signals in JavaScript, with async-IO resource adapters | Interop โ€” both speak wc-bindable, so the I/O nodes and DCCs are shared โ€” [Performance](#performance) |
163
166
 
164
167
  ## Installation
165
168
 
@@ -189,15 +192,15 @@ leaves features out, it can compose them instead:
189
192
  <script type="importmap">
190
193
  {
191
194
  "imports": {
192
- "@wcstack/state/core": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.5.4/dist/split/core.js",
193
- "@wcstack/state/features/temporal": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.5.4/dist/split/features/temporal.js",
194
- "@wcstack/state/features/scopes": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.5.4/dist/split/features/scopes.js"
195
+ "@wcstack/state/core": "https://cdn.jsdelivr.net/npm/@wcstack/state@4.0.0-rc.2/dist/split/core.js",
196
+ "@wcstack/state/features/temporal": "https://cdn.jsdelivr.net/npm/@wcstack/state@4.0.0-rc.2/dist/split/features/temporal.js",
197
+ "@wcstack/state/features/scopes": "https://cdn.jsdelivr.net/npm/@wcstack/state@4.0.0-rc.2/dist/split/features/scopes.js"
195
198
  }
196
199
  }
197
200
  </script>
198
201
  <script type="module">
199
202
  import { bootstrapState, installFeatures } from '@wcstack/state/core';
200
- import temporal from '@wcstack/state/features/temporal'; // $watch / $stream (and $scan: warns in 3.5, removed in 4.0)
203
+ import temporal from '@wcstack/state/features/temporal'; // $watch / $stream
201
204
  import scopes from '@wcstack/state/features/scopes'; // bind-component, mount=, DCC
202
205
 
203
206
  installFeatures([temporal, scopes]);
@@ -205,11 +208,12 @@ leaves features out, it can compose them instead:
205
208
  </script>
206
209
  ```
207
210
 
208
- Call `installFeatures([...])` **before** `bootstrapState()`, as above: a feature can register a tag of its
209
- own (`<wcs-ssr>`), and bootstrapping is what defines the tags. The reverse order is not an error โ€” a
210
- feature installed afterwards catches up and defines its tags against the registries already
211
- bootstrapped โ€” but keeping the declared order means every tag is defined before the first element
212
- upgrades.
211
+ **Install the add-ons before any state is defined** โ€” call `installFeatures([...])` before
212
+ `bootstrapState()`, as above. A state that declares an add-on's key (`$watch`, `$listKeys`, โ€ฆ) while
213
+ that add-on is not installed fails with `[wcs/feature-not-installed]`, and an engine created before an
214
+ add-on was installed does not gain it later. An add-on installed after `bootstrapState()` still
215
+ defines its own tag (`<wcs-ssr>`) in the registries already bootstrapped, but only the declared order
216
+ has every add-on in place before the first `<wcs-state>` starts.
213
217
 
214
218
  Load the split form from the package's own files โ€” jsDelivr's plain, version-pinned `/npm/` path as
215
219
  above (it does not read `exports`, so name the file under `dist/split/`), or a bundler โ€” and **never
@@ -218,27 +222,69 @@ core chunk into each one, so every entry would carry its own engine: a feature w
218
222
  copy the core never sees, and the page throws `[wcs/feature-not-installed]` in spite of
219
223
  `installFeatures`. From the plain files, every entry's relative import resolves to the same chunk URL,
220
224
  so the browser evaluates the engine once. Integrity for this form: [docs/sri.md ยง5.1](../../docs/sri.md#51-the-split-entries-of-wcstackstate).
225
+ The file names under `dist/split/chunks/` carry a content hash: if you list chunk files yourself
226
+ (preload links, `integrity` in an import map), take the names from the build you load.
221
227
 
222
228
  | Entry | What it adds |
223
229
  |---|---|
224
- | `@wcstack/state/core` | The binding engine: `data-wcs`, `for` / `if`, path getters, filters, events, `$command` / `$on`, `bootstrapState`, `installFeatures` |
225
- | `@wcstack/state/features/temporal` | `$watch`, `$scan` (warns in 3.5, removed in 4.0 โ€” [Preparing for 4.0](#preparing-for-40-wcsv4-migration)), `$stream` |
230
+ | `@wcstack/state/core` | The binding engine: `data-wcs`, `{{ }}` and comment bindings, `for` / `if`, path getters, events, `$command` / `$on`, the 24 logic filters (comparison, arithmetic, conversion, defaults), `bootstrapState`, `installFeatures` |
231
+ | `@wcstack/state/features/temporal` | `$watch`, `$stream` |
232
+ | `@wcstack/state/features/list-keys` | `$listKeys` |
226
233
  | `@wcstack/state/features/scopes` | `bind-component`, `mount=` volumes, overlay exports, DCC (`data-wc-definition`) |
227
234
  | `@wcstack/state/features/recursion` | `$recursion` and `**` paths |
228
235
  | `@wcstack/state/features/ssr` | `enable-ssr`: server rendering and hydration |
229
- | `@wcstack/state/features/formats` | The formatting filters (`upper`, `date`, `round`, `truncate`, โ€ฆ). The core answers only `not`, which `if` / `else` need |
236
+ | `@wcstack/state/features/formats` | The 23 formatting filters (`upper`, `date`, `round`, `truncate`, โ€ฆ) |
230
237
  | `@wcstack/state/features/devtools` | The DevTools hook protocol source |
231
- | `@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 |
238
+ | `@wcstack/state/features/diagnostics` | The sentences of error messages, with the did-you-mean and the pointer to lint, and the development-time warnings (a bound or `$watch` path that does not resolve, `wcs/default-getter-mismatch`). Without it a message is its code, its number and its values ([Error messages](#error-messages-and-numbers)), and those warnings are not printed |
232
239
  | `@wcstack/state/define` | `defineState` and the types only โ€” no runtime at all |
233
240
 
234
- A declaration whose feature is missing does not fail quietly: it throws
235
- `[wcs/feature-not-installed] โ€ฆ install it with installFeatures([...]) from "@wcstack/state/features/โ€ฆ"`
236
- when the state is defined, and a filter with no implementation throws `[wcs/filter-unknown]` when the
237
- bindings are planned. A volume (`<wcs-state mount=โ€ฆ>`) declaring `$watch` throws the same
238
- `[wcs/feature-not-installed]` when it grafts, because a volume's state never passes through the
239
- element's own declaration gate. Installing is idempotent, and every entry shares one core chunk โ€”
240
- a feature never carries a second copy of the engine, and never a copy of another feature either
241
- (a call across that seam goes through a receptacle the owning feature fills on install).
241
+ A declaration whose add-on is missing does not fail quietly: it throws
242
+ `[wcs/feature-not-installed] $watch needs the add-on @wcstack/state/features/temporal` when the state
243
+ is loaded (`mount=`, `bind-component` and `data-wc-definition` name `scopes` the same way, and
244
+ `enable-ssr` names `ssr`), and a formatting filter without `formats` throws `[wcs/filter-unknown]`
245
+ naming the add-on when the bindings are planned. Installing is idempotent โ€” an add-on installed
246
+ again is skipped โ€” and every entry shares one core chunk: an add-on never carries a second copy of
247
+ the engine, and never a copy of another add-on either (a call across that seam goes through a
248
+ receptacle the owning add-on fills on install).
249
+
250
+ #### The split auto entry
251
+
252
+ `dist/split/auto.js` loads the split build from one script tag, without an import map: it loads the
253
+ core, and the add-ons the page names from the same build.
254
+
255
+ ```html
256
+ <script type="module" src="https://cdn.jsdelivr.net/npm/@wcstack/state@4.0.0-rc.2/dist/split/auto.js"></script>
257
+ <wcs-state features="scopes diagnostics">
258
+ <script type="module">
259
+ export default {
260
+ $features: ["temporal", "formats"],
261
+ price: 1200,
262
+ $watch: { price(cur) { console.log(cur); } },
263
+ };
264
+ </script>
265
+ </wcs-state>
266
+ <p>{{ price|locale }}</p>
267
+ ```
268
+
269
+ The add-ons are named in two places, for two different reasons:
270
+
271
+ | | `features="โ€ฆ"` on the root `<wcs-state>` | `$features: [...]` in a state |
272
+ |---|---|---|
273
+ | Where | The document's root `<wcs-state>` โ€” the first one without `mount` and `bind-component` | Any state that is not a volume (a root, a mounted component, a DCC) |
274
+ | When it is read | Once, before `<wcs-state>` is defined | When that state is loaded, before its engine is built |
275
+ | What it is for | What must be there before any `<wcs-state>` starts โ€” `scopes`, which decides what a `mount=` / `bind-component` / DCC element is โ€” and the page's development aids (`diagnostics`, `devtools`) | The add-ons that state needs (`temporal` for its `$watch`, `formats` for its filters) |
276
+ | Who reads it | Only the split auto entry | Every entry: the split auto entry loads the missing add-ons; the other entries check that they are installed and fail the state with `[wcs/feature-not-installed]` otherwise |
277
+
278
+ The names are `formats`, `diagnostics`, `temporal`, `list-keys`, `scopes`, `recursion`, `ssr` and
279
+ `devtools`. Any other name in `features=` makes the split auto entry fail with `[wcs/feature-unknown]`
280
+ before it defines `<wcs-state>`; in `$features` it fails that state (`[wcs/feature-unknown]` on the
281
+ split auto entry, `[wcs/feature-not-installed]` elsewhere). A `$features` that is not an array throws
282
+ `#46`, and a volume may not declare one.
283
+
284
+ Load `dist/split/auto.js` from a plain, version-pinned `/npm/` path, never through `esm.run`, for the
285
+ reason above. It is not in `exports`: a bundler cannot follow the URLs it builds at run time, so a
286
+ bundled page imports `@wcstack/state/core` and calls `installFeatures` instead. The add-ons it imports
287
+ at run time are outside the `integrity` of its `<script>`; under a CSP they need the CDN host allowed.
242
288
 
243
289
  ## Basic Usage
244
290
 
@@ -332,6 +378,14 @@ a feature never carries a second copy of the engine, and never a copy of another
332
378
 
333
379
  Resolution order: `state` โ†’ `src` (.json / .js) โ†’ `json` โ†’ inner `<script>` โ†’ wait for `setInitialState()`.
334
380
 
381
+ A root `<wcs-state>` taken out of its root while its state loads (the page's content replaced, or the element moved) mounts nothing there and starts again where it is connected next, at once if it already is: a state given by `setInitialState()` is kept for that, but a `state` / `src` / `json` / inline-script source is read again โ€” a fetch runs again, and an inline module is evaluated again, its top-level code included.
382
+
383
+ `state="<id>"` reads only a `<script type="application/json">` with that id โ€” in the element's own
384
+ root first (a shadow root), then in the document; another element with the same id is not it. A
385
+ source that cannot be read fails the element's initialization: no such script (`#16`), a `.json`
386
+ that cannot be fetched (`#13`, with the HTTP status), JSON that does not parse, a module that throws
387
+ ([Initialization failures](#initialization-failures)). An empty JSON script is `{}`.
388
+
335
389
  The browser also evaluates form 5's `<script type="module">` itself โ€” being inside `<wcs-state>` does not stop it. Its export goes nowhere, so the state is unaffected, but its top-level code runs twice (once by the browser, once by state). Keep side effects (requests, logging, assignments to globals) out of the top level; under a CSP, see the note below.
336
390
 
337
391
  > **Under a Content-Security-Policy:** form 5 (inline `<script type="module">`) is evaluated through a `blob:` URL and therefore requires either the page's nonce on the `<script>` that loads state (the blob: import inherits it) or `script-src blob:`. If you enforce a strict CSP, use form 4 (`src="./state.js"`) instead โ€” it needs no extra directive. Note that the browser evaluates form 5's `<script>` itself as well: under a CSP that shows up as one violation in the console unless that `<script>` carries the nonce too, and otherwise (with that nonce, or with no CSP) its top-level code runs twice โ€” keep side effects out of it. See [docs/csp.md](../../docs/csp.md).
@@ -347,34 +401,30 @@ There is **one state tree per root**. To split state across modules, mount a vol
347
401
  <div data-wcs="textContent: cart.total"></div>
348
402
  ```
349
403
 
350
- 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.
404
+ A volume holds data, getters, setters and methods, and may declare `$connectedCallback` / `$disconnectedCallback`. Inside all of them `this` is rooted at the mount path: `this.items` is the tree's `cart.items`, and the `$` APIs that take a path (`$getAll`, `$setAll`, `$resolve`, `$postUpdate`, `$dependOn`, `$eq`, `$eqPath`, `$eqIndex`) take it relative to the mount. Its getters and setters become accessors of the tree at `cart.<key>`, and its methods are reachable by path โ€” `onclick: cart.checkout`, `this["cart.checkout"]()` from the root. Load order does not matter: a volume connected before the root is grafted when the root's engine is created, before the page is bound.
351
405
 
352
- **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:
406
+ A volume does not run the declarations that react to the whole tree. `$watch`, `$stream`, `$listKeys`, `$renderedCallback`, `$recursion`, `$behavior` and `$features` โ€” and the removed `$scan`, `$streams` and `$updatedCallback` โ€” refuse the volume: it is not grafted, and `console.error` names the element and says `$watch is not run in a volume โ€” declare it on the root state.` Declare them on the root state with full paths โ€” inside the root's handlers `this` is the root (`$watch: { "cart.total"(cur) { โ€ฆ } }`), and the root's `$renderedCallback(paths)` receives paths written from the root (`cart.items.*.name`), so filter them by the prefix. `$commandTokens`, `$eventTokens`, `$on` and `$errorCallback` are not run, with a `console.warn`. A volume takes no injections either: a `data-wcs` on the volume element (`state.taxRate: settings.taxRate`) refuses it โ€” read the root path in a root getter (`get cartTotalWithTax() { return this["cart.subtotal"] * (1 + this["settings.taxRate"]); }`).
353
407
 
354
- ```html
355
- <wcs-state mount="cart" src="./cart.js" data-wcs="state.taxRate: settings.taxRate"></wcs-state>
356
- ```
357
-
358
- 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`.
408
+ A volume that is refused settles without grafting, reports why, and resolves its `connectedCallbackPromise`; so does one whose state fails to load. Other refusals: a mount path another volume already holds (`will not graft: another volume already holds "cart".`), a path the root state already has (`will not graft: the root state already has "cart".` โ€” except under SSR, where the volume adopts the server's data there), and a volume in the shadow root of a component wired to its host (`will not graft: its component is wired to its host.` โ€” that component reads the host's tree; put the data in the host's state). If the root `<wcs-state>` fails to initialize, the volumes waiting for it, and those that load later, report `will not graft: the root state failed to initialize.` and settle instead of waiting forever. That is the end of the line for them: connecting a corrected root afterwards does not bring them back โ€” remove the broken root and the volumes and add new elements. A throw from a volume's `$connectedCallback` is reported with `console.error`, and the volume stays grafted.
359
409
 
360
- - **One key at a time.** The left side is a single `state.<key>`. `state: โ€ฆ` cannot move the whole volume; change `mount` for that.
361
- - **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.
362
- - **`#ro`.** With `state.taxRate#ro: settings.taxRate`, the value can be read but not written. An assignment from the volume's code, `$setAll`, or a writing `$resolve` throws `[wcs/mount-readonly]`; the root itself can still write the path.
363
- - **Static paths only.** The right side cannot contain a wildcard (`items.*.x`) or start with `$`, because a volume has no loop context. Filters are not accepted either; derive the value in a getter. A malformed injection is reported as `[wcs/mount-path-invalid]` before the volume loads.
410
+ A grafted volume keeps its data and its mount path when it is detached: there is no unmount. A volume still waiting for its root when it is taken out of the page (the page's content replaced) yields its mount path to a new volume that claims it, and settles without grafting. The root may write the paths under it, but a write that would replace an object a volume is grafted under (`this.shop = โ€ฆ` with `mount="shop.cart"`) throws, and so does re-setting the root (`setInitialState()`) or the volume. Mount paths must be static (`*`, `$`, `#`, `@` and whitespace are rejected). Changing `mount` after the element has initialized is not supported, and the change is ignored โ€” remove the element and add a new one with the desired path.
364
411
 
365
- **What each scope runs** (3.0 states it as one table โ€” requirement B11; nothing here is ignored silently):
412
+ **What each scope runs** (nothing here is ignored silently):
366
413
 
367
414
  | Declaration | Root `<wcs-state>` | Volume `<wcs-state mount="p">` | Mounted component (`bind-component` with `state: โ€ฆ`) |
368
415
  |---|---|---|---|
369
- | Data keys, getters, setters, methods | yes | yes, relative to `p` | yes โ€” own keys are private unless the host maps them; getters are exported |
416
+ | Data keys, getters, setters, methods | yes | yes, relative to `p`; methods reachable by path (`p.method`) | yes โ€” own keys are private unless the host maps them; getters are exported |
370
417
  | `$connectedCallback` / `$disconnectedCallback` | yes | yes, relative to `p` | yes |
371
- | `$watch`, `$listKeys`, `$renderedCallback` | yes | yes, relative to `p` | not run โ€” one `wcs/mount-dollar-declaration` warning |
372
- | `$stream`, `$scan`, `$recursion`, `**` getters | yes | rejected with an error before grafting | not run โ€” warning |
373
- | `$commandTokens`, `$eventTokens`, `$on` | yes | not run โ€” warning | not run โ€” warning |
374
- | `$errorCallback` | yes | not run โ€” warning (silent before 3.0) | not run โ€” warning (silent before 3.0) |
375
- | 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 |
418
+ | `$watch`, `$stream`, `$renderedCallback` | yes | refused: not grafted, `console.error` | not run โ€” one `wcs/mount-dollar-declaration` warning |
419
+ | `$listKeys` | yes | refused: not grafted, `console.error` | yes, on the component's own lists |
420
+ | `$recursion`, `**` getters | yes | refused | throws `[wcs/mount-dollar-declaration]` |
421
+ | `$commandTokens`, `$eventTokens`, `$on` | yes | not run โ€” `console.warn` | yes, on the component's own bindings |
422
+ | `$errorCallback` | yes | not run โ€” `console.warn` | yes, for the failures of the component's own bindings |
423
+ | `$behavior`, `$features` | yes | refused: not grafted, `console.error` | yes, for the component's own tree |
424
+ | An injection on the element (`data-wcs="state.<key>: โ€ฆ"`) | โ€” | refused: not grafted, `console.error` | โ€” (the host's wiring is the mount) |
425
+ | An initialization failure | reported once; `connectedCallbackPromise` rejects | settles without grafting; `connectedCallbackPromise` resolves (a missing `scopes` add-on rejects it) | reported once; the component's `connectedCallbackPromise` rejects |
376
426
 
377
- A component that is not mounted (a plain Shadow DOM child with its own `<wcs-state>`) is a root of its own and runs everything in the first column.
427
+ A component that is not mounted (a plain Shadow DOM child with its own `<wcs-state>`, or a Shadow DOM `bind-component` the host does not wire) is a root of its own and runs everything in the first column.
378
428
 
379
429
  > **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.
380
430
 
@@ -389,6 +439,12 @@ this["user.name"] = "Bob"; // path "user.name"
389
439
 
390
440
  That's the one rule: **assign to the path, and the DOM updates automatically.**
391
441
 
442
+ Writing a top-level key the state does not have creates it (`this.later = 5`). Reading one throws
443
+ `[wcs/binding-path-missing] Path "later" does not exist on the state tree.` (`#301`), so a binding to a
444
+ key the state declares nowhere fails ([Diagnostics](#diagnostics-and-failure-handling)) โ€” declare
445
+ every key you bind, `null` included, in the initial state. A deeper path that does not exist
446
+ (`user.nickname`) reads as `undefined`.
447
+
392
448
  ### Why `this.user.name = "Bob"` Doesn't Work
393
449
 
394
450
  This is not just a limitation. It is where the contract boundary becomes visible.
@@ -424,6 +480,10 @@ this.items.splice(index, 1);
424
480
  this.items.sort((a, b) => a.id - b.id);
425
481
  ```
426
482
 
483
+ Writing one element (`this["items.0"] = obj`) is a path assignment too: it replaces the value at that
484
+ position, and the row at that position shows the new value. To move rows together with their
485
+ values, assign a new array โ€” see [`$resolve`](#resolve--access-by-explicit-index).
486
+
427
487
  ## Binding Syntax
428
488
 
429
489
  ### `data-wcs` Attribute
@@ -438,12 +498,12 @@ Multiple bindings separated by `;`:
438
498
  <div data-wcs="textContent: count; class.over: count|gt(10)"></div>
439
499
  ```
440
500
 
441
- The separators `;` and `|` split only outside quotes (3.0), so a quoted filter argument may contain them: `textContent: tags|join('; ')`, `title: parts|join(' | ')`. Before 3.0 both broke the binding.
501
+ The separators `;` and `|` split only outside quotes, so a quoted filter argument may contain them: `textContent: tags|join('; ')`, `title: parts|join(' | ')`.
442
502
 
443
503
  | Part | Description | Example |
444
504
  |---|---|---|
445
505
  | `property` | DOM property to bind | `value`, `textContent`, `checked` |
446
- | `#modifier` | Binding modifier | `#ro`, `#prevent`, `#stop`, `#onchange` |
506
+ | `#modifier` | Binding modifier | `#ro`, `#prevent`, `#stop`, `#onchange`, `#direct` |
447
507
  | `path` | State property path | `count`, `user.name`, `users.*.name` |
448
508
  | `\|filter` | Transform filter chain | `\|gt(0)`, `\|round\|locale` |
449
509
 
@@ -461,16 +521,16 @@ The separators `;` and `|` split only outside quotes (3.0), so a quoted filter a
461
521
  | `attr.NAME` | Set an attribute (supports SVG namespace) |
462
522
  | `radio` | Radio button group binding (two-way) |
463
523
  | `checkbox` | Checkbox group binding to array (two-way) |
464
- | `onclick`, `on*` | Event handler binding |
465
- | `.NAME` | Explicit property (3.1): never an event, even when the name starts with `on` |
524
+ | `onclick`, `on*` | Event handler binding โ€” see [Event Handling](#event-handling) |
525
+ | `.NAME` | Explicit property: never an event, even when the name starts with `on` |
466
526
 
467
- **Properties whose names start with `on` (3.1).** A binding whose name starts with `on` is an event binding (`onclick:`). So `online: x` listens for a `"line"` event and never writes the element's `online` property. To bind such a property, put a dot in front of the name:
527
+ **Properties whose names start with `on`.** A binding whose name starts with `on` is an event binding (`onclick:`). So `online: x` listens for a `"line"` event and never writes the element's `online` property. To bind such a property, put a dot in front of the name:
468
528
 
469
529
  ```html
470
530
  <my-status data-wcs=".online: isOnline; onclick: refresh"></my-status>
471
531
  ```
472
532
 
473
- The dotted form is the same binding as the undotted one, except that it is never an event. `.value:` is two-way like `value:`, and modifiers and input filters work as usual. A namespace word (`.class`, `.attr`, `.style`, `.command`, `.eventToken`) or an empty name after the dot is rejected with `[wcs/binding-syntax]`.
533
+ The dotted form is the same binding as the undotted one, except that it is never an event. `.value:` is two-way like `value:`, and modifiers and input filters work as usual. A namespace word (`.class`, `.attr`, `.style`, `.command`, `.eventToken`, `.state`) or an empty name after the dot is rejected with `[wcs/binding-syntax]`.
474
534
 
475
535
  ### Modifiers
476
536
 
@@ -478,16 +538,17 @@ The dotted form is the same binding as the undotted one, except that it is never
478
538
  |---|---|
479
539
  | `#ro` | Read-only โ€” disables two-way binding |
480
540
  | `#prevent` | Calls `event.preventDefault()` on event handlers |
481
- | `#stop` | Calls `event.stopPropagation()` on event handlers |
541
+ | `#stop` | Calls `event.stopPropagation()` on event handlers โ€” on a delegated handler it stops the `on*:` handlers further out, not your own listeners on ancestors (see [Event Handling](#event-handling)) |
542
+ | `#direct` | On an `on*:` binding: attach the listener to the element itself instead of delegating it to the root โ€” see [Event Handling](#event-handling) |
482
543
  | `#onchange` | Uses `change` event instead of `input` for two-way binding |
483
544
  | `#init=<authority>` | Binding authority / initial sync direction โ€” see [Binding Authority](#binding-authority-init--sync) |
484
545
  | `#sync=<timing>` | Element snapshot timing โ€” see [Binding Authority](#binding-authority-init--sync) |
485
546
 
486
- Multiple modifiers are comma-separated after a single `#`: `value#ro,init=none: path`.
547
+ Multiple modifiers are comma-separated after a single `#`: `value#ro,init=none: path`, `onclick#direct,stop: save`.
487
548
 
488
- A modifier never changes the kind of binding: `radio#ro:` and `checkbox#ro:` stay radio / checkbox bindings (3.0). Before 3.0 a modifier turned them into a plain property named `radio`, so `#ro` on a radio group did not take effect.
549
+ A modifier never changes the kind of binding: `radio#ro:` and `checkbox#ro:` stay radio / checkbox bindings. A modifier the binding does not use is ignored (`#direct` on a property binding, an unknown `#foo`); an unknown `key=value` modifier (`#foo=1`) throws.
489
550
 
490
- As of 3.0 the parser rejects what it used to round off silently, with `[wcs/binding-syntax]`: a second `#` (`value#ro#wo` kept `ro` and dropped the rest โ€” write `value#ro,wo`), a value after `else:` (write `else:`), modifiers or left-side filters on `for` / `if` / `elseif` / `else` / `...`, and an unterminated quote in filter arguments.
551
+ The parser rejects, with `[wcs/binding-syntax]`: a second `#` (write `value#ro,wo`, not `value#ro#wo`), a value after `else:` (write `else:`), modifiers or left-side filters on `for` / `if` / `elseif` / `else` / `...`, output filters on `for:` (`#121` โ€” see [Loop](#loop-for)), a path segment `__proto__` or `prototype` (`#120` โ€” refused in writes, `$resolve`, `$setAll` and reads such as `this.__proto__` too), and an unterminated quote in filter arguments.
491
552
 
492
553
  ### Two-Way Binding
493
554
 
@@ -500,13 +561,13 @@ Automatically enabled for:
500
561
  | `<select>` | `value` | `change` |
501
562
  | `<textarea>` | `value` | `input` |
502
563
 
503
- `<input type="button">` is excluded. Use `#ro` to disable, `#onchange` to change the event.
564
+ `<input type="button">` is excluded. Use `#ro` to disable, `#onchange` to change the event. The listener that writes the value back sits on the element itself โ€” two-way bindings, radio and checkbox are never delegated.
504
565
 
505
566
  ### Binding Authority (`#init=` / `#sync=`)
506
567
 
507
568
  **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.
508
569
 
509
- 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`):
570
+ 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; `$behavior: { enableDirectionalInitialSync: false }` turns it off for a state tree โ€” see [`$behavior`](#behavior)):
510
571
 
511
572
  | Member declared in | Default authority | Effect |
512
573
  |---|---|---|
@@ -521,7 +582,7 @@ For custom elements that declare `static wcBindable`, every prop binding resolve
521
582
 
522
583
  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`.
523
584
 
524
- 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:
585
+ 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 `diagnostics` add-on (in the full entries) 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:
525
586
 
526
587
  ```javascript
527
588
  class YenInput extends HTMLElement {
@@ -578,9 +639,9 @@ With `sync=connect`, stateโ†’element writes stay suppressed until the connect sn
578
639
 
579
640
  Notes:
580
641
 
581
- - With `enableDirectionalInitialSync: false` (opt-out), writing `#init=`/`#sync=` throws.
582
- - **Migrating from โ‰ค 1.20:** do not seed state with placeholder values (`value: []`, `query: ""`) for output-only members โ€” the element's real initial value (often `null`/`undefined`) replaces the seed. Match the seed to the element's actual initial value and null-guard display values with a derived getter.
583
- - **Until 1.21.x**, `init=element` / `init=auto` / `init=none` suppressed stateโ†’element writes for the binding's whole lifetime, which made them unusable on genuinely two-way members. Authority now governs only the initial sync (`docs/architecture-hardening/09-remediation-design.md` ยง3.6).
642
+ - With `$behavior: { enableDirectionalInitialSync: false }` (opt-out), writing `#init=`/`#sync=` throws (`#31`), members are not checked against the declaration, and state wins every initial sync, output-only members included.
643
+ - Do not seed state with placeholder values (`value: []`, `query: ""`) for output-only members โ€” the element's real initial value (often `null`/`undefined`) replaces the seed. Match the seed to the element's actual initial value and null-guard display values with a derived getter.
644
+ - A binding to a member the declaration does not list throws (`Property "x" is not declared by wcBindable.`, `#38`); so does an `#init=` the member's shape does not allow (`#39`), and `sync=connect` on a member that is not an observable property (`#40`). Authority governs only the initial sync (`docs/architecture-hardening/09-remediation-design.md` ยง3.6).
584
645
 
585
646
  ### Radio Binding
586
647
 
@@ -618,14 +679,31 @@ A checkbox is checked when its `value` is included in the state array. Toggling
618
679
 
619
680
  ### Mustache Syntax
620
681
 
621
- When `enableMustache` is `true` (default), `{{ expression }}` in text nodes is supported:
682
+ `{{ expression }}` in a text node is a text binding โ€” a path with an optional filter chain, the same as the right side of `textContent:`:
622
683
 
623
684
  ```html
624
685
  <p>Hello, {{ user.name }}!</p>
625
686
  <p>Count: {{ count|locale }}</p>
626
687
  ```
627
688
 
628
- Internally converted to comment-based bindings (`<!--@@:expression-->`).
689
+ The text node is split at the braces: the literal text stays, and each `{{ }}` becomes a text node of its own that shows the value. `$behavior: { enableMustache: false }` turns this off for a state tree ([`$behavior`](#behavior)); the braces then stay as text.
690
+
691
+ Before the state loads, a `{{ }}` outside a `<template>` is visible as it is written. Where that flash matters and the page is not server-rendered, write a comment binding instead.
692
+
693
+ #### Comment bindings
694
+
695
+ `<!--@@: expression-->` (or `<!--@@wcs-text: expression-->`) is the same text binding as `{{ expression }}`, filters included, written as a comment โ€” so nothing shows before it is bound, and no element is added:
696
+
697
+ ```html
698
+ <p>Hello, <!--@@: user.name-->!</p>
699
+ <p>Total: <!--@@wcs-text: total|locale--></p>
700
+ ```
701
+
702
+ - The comment is replaced by a text node at the same position, at page level and inside `for:` / `if:` templates.
703
+ - It is bound whatever `enableMustache` says: turning `{{ }}` off does not turn comment bindings off.
704
+ - The keyword is empty or `wcs-text`; any other comment (`<!--@@route:โ€ฆ-->`, the anchors this package and `@wcstack/server` write, ordinary comments) is left alone. The expression may span several lines.
705
+ - A comment inside `<textarea>` or `<title>` is not bound: a browser parses that content as text.
706
+ - `@wcstack/lint` and the VS Code extension recommend this form over a `{{ }}` outside a `<template>`.
629
707
 
630
708
  ### Spread Binding (`...`)
631
709
 
@@ -666,20 +744,34 @@ Runtime reads `customClass.wcBindable.properties + inputs` and expands each name
666
744
  <wcs-fetch data-wcs="...: usersFetch; status: alternateStatus"></wcs-fetch>
667
745
  ```
668
746
 
669
- **`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".
747
+ **`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.) **Display surfaces are different:** `textContent` / `innerText` / `innerHTML`, mustache and comment-binding 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. `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".
670
748
 
671
- **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)`).
749
+ **Filters keep the contract.** The emptiness test runs on the value *after* the filters, so a filter that built its result with `String(value)` would turn an absent value back into the characters `"undefined"`. The formatting family (`upper`, `lower`, `capitalize`, `trim`, `slice`, `padStart`, `padEnd`, `repeat`, `reverse`, `truncate`, `unit`) passes `undefined` / `null` straight through instead, so `attr.title: x|trim` removes the attribute just as `attr.title: x` does. 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)`).
672
750
 
673
751
  **Constraints**:
674
752
 
675
753
  - Filters on the spread target (`...: target|filter`) are rejected.
676
754
  - The right-hand path may contain `*` anywhere (e.g. `...: stores.*.fetch`).
677
755
  - The right-hand side is a plain tree path (`...: fetchX` or `...: stores.*.fetch`).
678
- - If the custom element class is not yet registered, expansion is deferred until `customElements.whenDefined(tag)` resolves โ€” autoloader-style late registration is supported. This holds inside `for:` / `if:` templates too: the rows and the branch render right away, and each row's element is expanded with that row's values once the class is defined. A row removed, or a branch closed, before the definition is not expanded. An element taken out of the document while it waits (a tab or panel parked off-DOM) holds nothing in the registry while it is out; once it is back, it waits again on the registry of the tree it is back in, and is expanded when the class is defined (at once if it already is) โ€” for an element of a row, only while that row is still in the list.
679
- - Elements **without** a `wcBindable` declaration are rejected (write bindings explicitly). Spread requires the contract to know what to expand.
756
+ - If the custom element class is not yet registered, expansion is deferred until `customElements.whenDefined(tag)` resolves โ€” autoloader-style late registration is supported. This holds inside `for:` / `if:` templates too: the rows and the branch render right away, and each row's element is expanded with that row's values once the class is defined. A row removed, or a branch closed, before the definition is not expanded, and holds nothing alive. The wait is on the registry that defines the element โ€” a scoped registry of the shadow root it is placed in included.
757
+ - Elements **without** a `wcBindable` declaration are rejected (`[wcs/spread-no-bindable]`; write bindings explicitly) โ€” a native element at once, without waiting for a definition. Spread requires the contract to know what to expand.
680
758
 
681
759
  **Composite shells** (wc-bindable Composition Profile) are supported transparently: a composite shell exposes its synthesized declaration through the standard `target.constructor.wcBindable` surface, and composed names like `"s3.progress"` are kept as flat element member keys. Mirror the composed structure in state (`{ s3: { progress: 0 } }`) and `...: pipeline` expands into nested state paths automatically.
682
760
 
761
+ ### What the page walk binds
762
+
763
+ When a `<wcs-state>` binds its root (the document, a shadow root, or a Light DOM component's host), it walks the markup once and reads the `data-wcs` attributes, `{{ }}` and comment bindings in it:
764
+
765
+ - **A value is never markup.** What a binding puts into the page โ€” a `textContent:` or `innerHTML:` value, the children a custom element renders from a bound value โ€” is never read as bindings: `{{ }}`, `data-wcs` and comment bindings in it stay text.
766
+ - The children of an element whose content a binding sets (`textContent:`, `text:`, `innerText:`, `innerHTML:`, `html:`) are not bound, at page level or inside templates โ€” also when `#init=element` or `#init=none` leaves the children you wrote in place. Nor are the children of a page-level element bound with `outerHTML:` / `outerText:`. Put markup that needs bindings in a separate element.
767
+ - The content of `<script>`, `<style>`, `<noscript>` and `<iframe>` is not walked; the element's own `data-wcs` (`srcdoc:`, `attr.src:`) still binds.
768
+ - Markup written inside a `<wcs-state>` element is part of the page. A Light DOM component's content is bound by the component's own engine, not by the page.
769
+ - At page level an element's children are bound before the element's own bindings, so a custom element receives its first property writes after its children are bound, and `$errorCallback` receives failures children first. Inside templates the order is document order.
770
+ - `for:` rows and `if:` branches are clones of their templates without the `data-wcs` attribute, so a CSS rule or a test selector on `[data-wcsโ€ฆ]` does not match those elements โ€” use a class or a `data-*` attribute of your own.
771
+ - Inside a `for:` / `if:` template, a binding that replaces its element (`outerHTML:`, `outerText:`) fails initialization with `[wcs/template-syntax] #203`: a row keeps its nodes by position. Bind `innerHTML:` on a wrapper element instead. (`class.outerHTML:`, `attr.outerText:` are not affected.)
772
+
773
+ Markup that you did not write must not reach this walk โ€” see [Security](#security).
774
+
683
775
  ## Structural Directives
684
776
 
685
777
  Structural directives use `<template>` elements:
@@ -703,6 +795,17 @@ This means **no explicit `key` attribute is needed for adding, removing, or reor
703
795
 
704
796
  The exception is data that arrives as **freshly created objects** โ€” `fetch(...).json()`, `JSON.parse` from storage, a WebSocket/SSE full snapshot, or a Worker `postMessage`. Those rows never match by reference, so every row is torn down and rebuilt. See [`$listKeys`](#listkeys--identity-for-refetched-rows) below.
705
797
 
798
+ **`for:` takes no output filters.** `for: items|take(2)` fails initialization with `[wcs/binding-syntax] #121`, whatever the filter. A row of `for: items` is `items.<index>`, so the rows of a filtered array would name other elements, and writes through them would land on the wrong ones. Declare a getter that returns the filtered list, and loop over it:
799
+
800
+ ```html
801
+ <!-- with get firstTwo() { return this.items.slice(0, 2); } -->
802
+ <template data-wcs="for: firstTwo">
803
+ <li data-wcs="textContent: .name"></li>
804
+ </template>
805
+ ```
806
+
807
+ The getter returns a copy, so a write through the original path (`this["items.1.name"] = "z"`) does not reach the copy's row; write through the row instead (`firstTwo.1.name`, or a binding inside the row). The same holds when one object is reachable from two lists (a `get shown()` that filters `todos`, rendered with `for: shown`): see [One object at two positions](#resolve--access-by-explicit-index).
808
+
706
809
  #### `$listKeys` โ€” identity for refetched rows
707
810
 
708
811
  When rows carry DOM state the bindings do not own โ€” focus, an in-flight IME composition, `<details>` open state, inner scroll position, `<canvas>` contents, `<video>` playback โ€” rebuilding the rows loses it. Declare a key so the framework can recognize rows across a refresh:
@@ -732,7 +835,9 @@ Notes:
732
835
  - **A no-op refresh is free.** If nothing changed, no field is written and no DOM work happens at all.
733
836
  - **Rows must be plain objects**, and keys must be present and unique. Duplicate keys, missing keys, and class instances raise an error immediately rather than degrading silently.
734
837
  - **Fields dropped from a row are cleared with `null`**, which is this package's vocabulary for an explicit clear (`undefined` means "the state has no opinion" and skips the write).
838
+ - A field whose name is not one path segment (`@odata.etag`, `2fa`, `a.b`), or that has the name of a row getter, is copied into the kept row as it is, and the row is updated as a whole. A `__proto__` key from JSON is not copied.
735
839
  - The stored array is rebuilt from matched row objects, so `this.items !== theArrayYouAssigned` afterwards.
840
+ - `$listKeys` is the `list-keys` add-on: the full entries include it; a `/core` page installs `@wcstack/state/features/list-keys`. A mounted component's `$listKeys` keys its own lists; a volume may not declare one.
736
841
 
737
842
  #### Dot Shorthand
738
843
 
@@ -783,6 +888,20 @@ Nested loops are supported with multi-level wildcards. The `.` shorthand in nest
783
888
 
784
889
  Conditions can be chained. `elseif` automatically inverts the previous condition.
785
890
 
891
+ ### Structural templates in inserted content
892
+
893
+ Content another package inserts after the page was bound โ€” a route body that `@wcstack/router` shows, a `<wcs-head>` child โ€” is bound when the inserter hands it over through the binder protocol (`globalThis[Symbol.for("wcstack.binder")].bind(subtree, options?)`). A `for:` / `if:` template at the **top** of such content renders beside itself, where an inserter that moves only its own nodes would leave the rows behind, so it is rendered only when the inserter declares that it carries the whole range in and out โ€” `bind(subtree, { range: true })`, as `@wcstack/router` 3.5 and later does for route content. Without that declaration it is not rendered, and the console reports `[wcs/template-syntax] #204`. A template wrapped in an element renders in every case:
894
+
895
+ ```html
896
+ <wcs-route path="/items">
897
+ <ul>
898
+ <template data-wcs="for: items"><li>{{ .name }}</li></template>
899
+ </ul>
900
+ </wcs-route>
901
+ ```
902
+
903
+ The binder never throws for markup reasons: an error in a handed-over subtree is reported with `console.error`, what comes before it stays bound, and the navigation goes on. A subtree handed over again (the router hands its content over on every insertion) is bound once. Only elements are handed over, so a `{{ }}` written directly in a route body, outside any element, is not bound when the route is entered by navigation โ€” wrap it in an element. This matters only if you call the binder yourself, or write `for:` / `if:` directly under `<wcs-route>`.
904
+
786
905
  ## Path Getters (Computed Properties)
787
906
 
788
907
  **Path getters** are the core feature of `@wcstack/state`. Define computed properties using JavaScript getters with **dot-path string keys** containing wildcards (`*`). They act as **virtual properties that can be attached at any depth in a data tree โ€” all defined flat in one place**. No matter how deeply data is nested, path getters keep definitions at the same level with automatic dependency tracking per loop element.
@@ -1052,13 +1171,15 @@ Two-way binding works with path setters โ€” editing the input calls the setter,
1052
1171
 
1053
1172
  ### How It Works
1054
1173
 
1055
- 1. **Context resolution** โ€” When a `for:` loop renders, each iteration pushes a `ListIndex` onto the address stack. Inside a path getter, `this["users.*.name"]` resolves the `*` using this stack, so it always points to the current element.
1174
+ 1. **Context resolution** โ€” Every element of a list is a row, with its position. Inside a path getter evaluated for a row (a `for:` row, an event handler bound in one, a `$getAll` walk), `this["users.*.name"]` resolves each `*` to that row and its ancestors, so it always points to the current element. A `*` resolves only through the row of *that* list: a row of another list is not a context for it (`wcs/wildcard-rank`).
1175
+
1176
+ 2. **Automatic dependency tracking** โ€” When a getter accesses `this["users.*.name"]`, the system registers a dependency from `users.*.name` to the getter's path. When `users.*.name` changes, the getter's cache is dirtied.
1056
1177
 
1057
- 2. **Automatic dependency tracking** โ€” When a getter accesses `this["users.*.name"]`, the system registers a dynamic dependency from `users.*.name` to the getter's path. When `users.*.name` changes, the getter's cache is dirtied.
1178
+ 3. **Caching** โ€” Getter results are cached per row. `users.*.fullName` at index 0 has a separate cache entry from index 1. The cache is invalidated only when dependencies change.
1058
1179
 
1059
- 3. **Caching** โ€” Getter results are cached per concrete address (path + loop index). `users.*.fullName` at index 0 has a separate cache entry from index 1. The cache is invalidated only when dependencies change.
1180
+ 4. **Direct index access** โ€” You can also address a row by its index: `this["users.0.name"]` is the row at index 0 now, without needing loop context. Writing an element (`this["users.0"] = { ...this["users.0"], name: "z" }`) replaces the value at that position: the row stays where it is, takes the new value, and everything derived below it (`users.0.name`, `$getAll("users.*.name")`, row getters) is computed again โ€” whether or not a `for:` renders the list. Changing the object in place and announcing it with `$postUpdate("users.0")` / `$postUpdate("users")` keeps the row and re-reads what is below it. A binding path with numeric indexes means the same thing โ€” *whatever row is at index 0 now*: `textContent: users.0.name`, `{{ users.1.name }}`, a row getter such as `{{ users.0.fullName }}`, and paths with several indexes (`groups.0.items.1.v`). It follows writes through an index path, through a `for:` row or an input bound to that row, element replacement, reordering, removals and list replacement. A `$watch` key with a numeric index (`"users.0.name"`) fires only when the value at that index changes. An index past the end of a list reads as `undefined`, and a write there throws `no row for "users.*.name"` (`#3`) and changes nothing.
1060
1181
 
1061
- 4. **Direct index access** โ€” You can also access specific elements by numeric index: `this["users.0.name"]` resolves as `users[0].name` without needing loop context. Assigning a different object to an element path (`this["users.0"] = { ...this["users.0"], name: "z" }`) makes that position a new row, whether or not a `for:` renders the list, so the paths below it (`users.0.name`, `$getAll("users.*.name")`, row getters) read the new object instead of the cached values of the row it replaced. Assigning the same object back after changing it in place (`const u = this["users.0"]; u.name = "z"; this["users.0"] = u;`), or announcing the change with `$postUpdate("users.0")` / `$postUpdate("users")`, keeps the row and re-reads the paths below it and the getters that read them, also with no `for:` rendering them inside the row โ€” before this fix those paths kept their cached values unless a `for:` rendered them, and a getter reading `users.0.name` could miss a replaced element when a `$getAll` over the same list was evaluated first. A binding path with one numeric index can use the same spelling, with the same meaning โ€” *whatever row is at index 0 now*: `textContent: users.0.name`, `{{ users.1.name }}`, or a row getter such as `{{ users.0.fullName }}`. It follows a write through an index path (`this["users.0.name"] = โ€ฆ`), a `for:` row or an input bound to that row, replacing the element (`this["users.0"] = {โ€ฆ}`), reordering, removing rows and replacing the list, and a row getter follows its inputs โ€” with or without a `for:` rendering the list. Such a binding is read like a getter, through a non-enumerable accessor added to the state object, and like a getter it is re-evaluated when any row of the list is written; a `$watch` key such as `"users.0.name"` can therefore fire with an unchanged value. An index with no row behind it (an empty list) reads as empty, and a key under an object that is not an array (`sales.2024.total`) reads as a plain key. Through 3.3.0, such a binding kept its first value and followed only replacements of the whole list, and a row getter spelled this way rendered empty with a `wcs/binding-path-missing` warning. A path with two or more numeric indexes (`groups.0.items.1.v`) still behaves that way, as does any numeric-index binding on a state object that cannot be extended (`Object.freeze`, `Object.seal`, `Object.preventExtensions`).
1182
+ Numeric keys under a plain object (`sales.2024.total`, `usersById.42.name`) are a known limitation of this version: markup renders them, but a script read (`this["sales.2024.total"]`, also inside a getter) gives `undefined`, `$eq` on such a path is always false, and a write or a two-way write-back throws `no row for "sales.*.total"`. Read them as `this.sales[2024].total`, write by assigning a new object to the top-level key (`this.sales = { ...this.sales, 2024: { ...this.sales[2024], total: 10 } }`), or use keys that are not numbers.
1062
1183
 
1063
1184
  ### Getters must be pure with respect to state
1064
1185
 
@@ -1099,7 +1220,7 @@ Three rules decide what the dependency graph sees. None of them matters until yo
1099
1220
  |---|---|
1100
1221
  | **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"]` |
1101
1222
  | **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 |
1102
- | **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) |
1223
+ | **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` (`$behavior.sameValueGuard`; a `semantics: "event"` property is exempt either way) |
1103
1224
 
1104
1225
  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.
1105
1226
 
@@ -1134,8 +1255,8 @@ What reaches the rows:
1134
1255
 
1135
1256
  - **A write to `path`** โ€” of any value, objects included (`this.selected = row` with `$eq("selected", this["items.*"])`): the row that was selected and the row that becomes selected.
1136
1257
  - **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.
1137
- - **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).
1138
- - **A `path` with a numeric index** (`$eq("items.0.v", โ€ฆ)`): a write reports the index as a wildcard (`items.*.v`, `items.*`), so a subscription under the spelled path would never hear it; these calls fall back to a tracked read too, and follow a write to that row's `v`, a replacement of the element and of the list (a write to another row's `v` also re-evaluates them, as with any getter). Before this fix such a getter kept its first value unless a binding on the same numeric path existed.
1258
+ - **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**; a path that fell back this way holds none, so it is not listed there.
1259
+ - **A `path` inside a list** โ€” a wildcard (`$eq("items.*.v", โ€ฆ)`) or a numeric index (`$eq("items.0.v", โ€ฆ)`): `$eq` / `$eqPath` key only paths outside lists, so these calls fall back to a tracked read too, and follow a write to that row's `v`, a replacement of the element and of the list (a write to another row's `v` also re-evaluates them, as with any getter).
1139
1260
  - **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.
1140
1261
  - **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.
1141
1262
 
@@ -1183,8 +1304,8 @@ Inside state objects (getters / methods), the following APIs are available via `
1183
1304
  | `this.$setAll(path, indexes, value, options?)` | Write to every address matching a wildcard path |
1184
1305
  | `this.$resolve(path, indexes, value?)` | Resolve a wildcard path with specific indexes |
1185
1306
  | `this.$postUpdate(path)` | Manually trigger update notification for a path |
1186
- | `this.$dependOn(path)` (3.2; old name `$trackDependency`) | Manually register a dependency for cache invalidation |
1187
- | `this.$untracked(fn)` (3.2; old name `$untrackDependency`) | Read values inside fn without registering dependencies |
1307
+ | `this.$dependOn(path)` | Manually register a dependency for cache invalidation |
1308
+ | `this.$untracked(fn)` | Read values inside fn without registering dependencies |
1188
1309
  | `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)) |
1189
1310
  | `this.$command.<name>` | Access a `CommandToken` declared in `$commandTokens` (see [Command Token](#command-token-method-binding)) |
1190
1311
  | `this.$stateElement` | Access to the `IStateElement` instance |
@@ -1269,12 +1390,21 @@ One thing `$setAll` is not: a shortcut for the dependency walk. Rendering still
1269
1390
 
1270
1391
  #### `$resolve` โ€” Access by Explicit Index
1271
1392
 
1272
- `$resolve` reads or writes a value at a specific wildcard index. **The argument count decides which** (3.0): `$resolve(path, indexes)` reads, and `$resolve(path, indexes, value)` writes `value` โ€” `undefined` included (before 3.0 an `undefined` third argument was a read). On a readonly proxy the write throws `This state is readonly.`, exactly as a direct assignment does, and so does `$setAll` (before 3.0 both helpers wrote through a readonly proxy):
1393
+ `$resolve` reads or writes a value at a specific wildcard index. **The argument count decides which**: `$resolve(path, indexes)` reads, and `$resolve(path, indexes, value)` writes `value` โ€” `undefined` included. On a readonly proxy the write throws `This state is readonly.`, exactly as a direct assignment does, and so does `$setAll`. An index past the end of the list reads `undefined`; a write there throws `no row for "items.*"` and changes nothing.
1394
+
1395
+ **Writing an element replaces the value at that position.** The row at that position stays where it is, with its `$1`, and takes the new value: its bindings show it, and everything derived below the row is computed again. DOM state that bindings do not own โ€” focus, text typed into an unbound input, `<details>` open state โ€” stays with the position. An input bound to the row (`value: .`) therefore keeps focus while you type, and an element inside a row that writes its own row (`value: .`, a wc-bindable output bound with `status: .`) keeps landing on that row. To move rows **together with their values**, assign a new array: rows are matched to the elements of the new array by identity, so they move with their objects.
1273
1396
 
1274
1397
  ```javascript
1275
1398
  export default {
1276
- items: ["A", "B", "C"],
1399
+ items: [{ name: "A" }, { name: "B" }, { name: "C" }],
1400
+ // โœ… the two rows trade places, with their DOM
1277
1401
  swapFirstTwo() {
1402
+ const items = this.items.slice();
1403
+ [items[0], items[1]] = [items[1], items[0]];
1404
+ this.items = items;
1405
+ },
1406
+ // the values trade places; the rows stay where they are
1407
+ swapValues() {
1278
1408
  const a = this.$resolve("items.*", [0]);
1279
1409
  const b = this.$resolve("items.*", [1]);
1280
1410
  this.$resolve("items.*", [0], b);
@@ -1283,13 +1413,7 @@ export default {
1283
1413
  };
1284
1414
  ```
1285
1415
 
1286
- Swapping rows this way moves the rendered rows with their values once the swap is complete: the row blocks are reordered rather than rewritten in place, so a row's `$1` and any state it holds outside bindings (such as text typed into an unbound input) follow the value. Writing a value that was not in the list replaces that row in place: its block stays where it is and its bindings show the new value, so an input bound to the row keeps focus while you type. In a list of primitives, equal values cannot be told apart, so writes that end in a reordering of the same values count as a swap.
1287
-
1288
- The swap is complete once every value you moved has left the position it came from. Until then โ€” between the two writes, while the moved value sits at both positions โ€” the rows keep their blocks and show the values by position, and a numeric index (`this["items.0"]`) reads and writes that position. The two writes may also run in separate updates (two clicks, say): the update that completes the swap reports to a `$watch` on `items.*` only the position it wrote, with that position's `prev`. Assigning an array before the swap completes โ€” even an array the path held before โ€” renders that array's values. A list that holds equal values in separate rows swaps like any other, but a write that copies a value another row keeps holding cannot be told apart from the first half of a swap. So once your code leaves rows holding equal values for good, its index writes into that array are rendered by position until the array is replaced: the values and `$1` are right, but the blocks stop following the values.
1289
-
1290
- None of this applies to a value that an element inside a row writes to its own row โ€” an input bound with `value: .`, or a wc-bindable output bound with `status: .`. That write updates the row's value and never counts as part of a swap: the row keeps its block and its `$1`, and the element's later writes keep landing on its own row, even when several rows report the same value or one row's element writes a value another row holds.
1291
-
1292
- **One object at two positions.** When the same object sits at two positions of a list (`items: [o, o, โ€ฆ]`), a write to a key below one of those rows (`this["items.0.name"] = "z"`) changes the object, but the other row keeps showing and reading the old value: values below a row are cached per row, and a write reaches only the row it was made through. Call `$postUpdate("items")` after such a write, and every row โ€” the `for:` rows, `$getAll("items.*.name")` and index paths such as `items.1.name` โ€” reads the object again.
1416
+ **One object at two positions.** When the same object sits at two positions of a list (`items: [o, o, โ€ฆ]`), a write to a key below one of those rows (`this["items.0.name"] = "z"`) changes the object, but the other row's bindings and row getters keep showing the old value: a write reaches only the row it was made through. Plain reads, root getters and `$getAll` see the new value. The same happens when one object is reachable from two lists, as in a TodoMVC-style filter โ€” `get shown()` returning a filtered copy of `todos`, rendered with `for: shown`, while a checkbox writes the row. Call `$postUpdate("items")` after such a write, and every row of that list shows the object again. This is a known limitation of this version.
1293
1417
 
1294
1418
  ## Recursive Paths (`$recursion`)
1295
1419
 
@@ -1328,7 +1452,7 @@ export default {
1328
1452
 
1329
1453
  For that forest the totals are `131 / 110 / 100 / 20 / 2` and `treeTotal` is `133`.
1330
1454
 
1331
- **`**` 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.
1455
+ **`**` 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 โ€” the path pattern, 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.
1332
1456
 
1333
1457
  ### Declaring the recursion point
1334
1458
 
@@ -1340,7 +1464,7 @@ $recursion: { "data.tree.*": "kids.*" } // a deeper anchor is fine
1340
1464
  $recursion: { "nodes.*": "nodes.*" } // self-similar spelling is fine too
1341
1465
  ```
1342
1466
 
1343
- 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.
1467
+ 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. Three of these checks โ€” a suffix naming the structure, two getters expanding to one path, and a concrete getter with the same name as an expansion โ€” are made by the `diagnostics` add-on (in the full entries); a `/core` page without it does not make them.
1344
1468
 
1345
1469
  The family a declaration defines is infinite, and the state only ever grows the depths it is asked for:
1346
1470
 
@@ -1364,7 +1488,7 @@ k=2 nodes.*.children.*.children.*
1364
1488
  | `$setAll(path, [], value)` | Broadcast to every depth, in that same order |
1365
1489
  | `$resolve`, `$postUpdate`, `$dependOn`, `$watch` keys, `$listKeys` keys, `data-wcs` in markup, direct assignment | Rejected (`wcs/recursion-unsupported`) |
1366
1490
 
1367
- 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.
1491
+ 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 runs for a real row 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.
1368
1492
 
1369
1493
  ```javascript
1370
1494
  this.$getAll("nodes.**.value", []); // [1, 10, 100, 20, 2] โ€” depth-first, pre-order
@@ -1428,11 +1552,13 @@ Every other form is refused *before* the walk writes anything, so a rejected cal
1428
1552
  | Form | Why it is refused |
1429
1553
  |---|---|
1430
1554
  | a non-empty prefix | A prefix cannot say which depth it applies to (`wcs/recursion-setall-form`) |
1431
- | omitted indexes | The write API takes no context, so there is no depth to bind to โ€” pass `[]` |
1432
- | a mapper function | `(current, ...indexes)` has a different arity at every depth |
1433
- | `{ spread: true }` | Handing a flat array to a tree needs the author to know the walk order |
1555
+ | omitted indexes | The write API takes no context, so there is no depth to bind to โ€” pass `[]` (`#9`, as any `$setAll` without `indexes`) |
1556
+ | a mapper function | `(current, ...indexes)` has a different arity at every depth (`wcs/recursion-setall-form`) |
1557
+ | `{ spread: true }` | Handing a flat array to a tree needs the author to know the walk order (`wcs/recursion-setall-form`) |
1434
1558
  | `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`) |
1435
- | `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`) |
1559
+ | `nodes.**.total`, or a path inside its value โ€” a child node's path folds to the same form, so `nodes.**.children.0.total` and `nodes.**.children.*.total` are the getter too, and a getter declared under a child node (`get "nodes.**.children.*.x"`) refuses `nodes.**.x` as well | A recursive getter has no setter โ€” write what it derives from (`wcs/recursion-readonly`) |
1560
+
1561
+ Everything else is a leaf, a leaf under a child node included: `$setAll("nodes.**.children.*.value", [], v)` writes the `value` of every node's children โ€” every node but the roots.
1436
1562
 
1437
1563
  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.
1438
1564
 
@@ -1440,9 +1566,9 @@ The read-only rule does not depend on spelling `**`. A recursive getter's concre
1440
1566
 
1441
1567
  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.
1442
1568
 
1443
- Replacing a row object while keeping its `children` array โ€” `this.nodes = this.nodes.map(n => ({ ...n }))` โ€” is an ordinary update, and the aggregates follow it. The child list keeps its existing row objects โ€” so anything keyed by row identity, such as a `bind-component` child scope's rendered rows and any state you have not bound there, survives โ€” and only the retired row they hung under is swapped for the live one, so the next leaf update dirties the row that is actually on screen ([#256](https://github.com/wcstack/wcstack/issues/256)). Two rows *sharing* one `children` array is a different thing. While both rows are in the list it is unchanged: an array has one set of rows, so both rows always agree on every value, and a row getter that reads its parent (`this["nodes.*.value"]`) is evaluated in the context of the row that owns those rows โ€” the one that first expanded the array. What changed is what happens when that owner is removed from the list: the rows follow one of the rows still in the list (it can be a row that `if:` hides), so that row's aggregate tracks the shared data instead of freezing at the removed row's numbers โ€” an array still has one set of rows, so with three rows sharing one array a single survivor follows and the others stay frozen. Since 3.4.0 the same hand-over happens when the owner stays in the list but stops holding the array (`this["nodes.0.children"] = [ โ€ฆ ]`): the rows go to another row that holds it under the same key, and that row's aggregate tracks the shared data โ€” 3.3.0 left it frozen. Putting the removed row back hands them straight back to it when that row's own object comes back โ€” whether you reassign the same array instance, build a new array holding the same rows, or put the row back at a different position. When every row is rebuilt instead (`this.nodes = this.nodes.map(n => ({ ...n }))`), no row object matches and the rows end up under whichever row now occupies the owner's old position. Only the same-array-instance restore behaves this way on 2.3.0; restoring with a new array leaves both rows frozen there. Give every node its own array when a child getter reads upward: after the owner stops holding the array, such a getter can keep returning values computed for the old owner until something it reads changes.
1569
+ Replacing a row object while keeping its `children` array โ€” `this.nodes = this.nodes.map(n => ({ ...n }))` โ€” is an ordinary update, and the aggregates follow it.
1444
1570
 
1445
- Three sharing shapes are known limits. When different outer rows hold the same array under **different keys** (one as `items`, another as `alt`), a write through one key can land on another element ([#396](https://github.com/wcstack/wcstack/issues/396)). Do not share one array between two `<wcs-state>` elements, a root and its volume included: after a copy or a rebuild in one, a write through the other can land on the wrong array, or throw and be lost ([#397](https://github.com/wcstack/wcstack/issues/397)). And stacking copies, moves and rebuilds of shared arrays in one update can leave some `for:` blocks showing old values while the state is right ([#398](https://github.com/wcstack/wcstack/issues/398)).
1571
+ Outside a recursive walk, rows may share one array (the walk refuses it, as above). Every list over the same array instance shows a write made through any one of them โ€” a write below `groups.0.items.1` also reaches `groups.2.items.1` when both hold that array, under the same key or another. One object reached through two *different* arrays (a copy of a shared array, a getter returning a filtered copy) is the known limitation described under [One object at two positions](#resolve--access-by-explicit-index).
1446
1572
 
1447
1573
  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.
1448
1574
 
@@ -1524,11 +1650,36 @@ export default {
1524
1650
  </template>
1525
1651
  ```
1526
1652
 
1653
+ `this` in a handler is the writable state. A dotted name is a path: `onclick: cart.checkout` calls the method of the volume mounted at `cart`. A handler that throws, or returns a Promise that rejects, is reported with `console.error`; the event is not affected.
1654
+
1655
+ ### Delegated events
1656
+
1657
+ `on*:` bindings for `click`, `dblclick`, `input`, `change`, `submit`, `keydown`, `keyup`, `mousedown`, `mouseup`, `pointerdown` and `pointerup` are **delegated**: one listener per event type sits on the root the state binds โ€” the document, a shadow root, or, inside a Light DOM mounted component, the host element โ€” and it runs the handlers of the elements the event passed, innermost first. So:
1658
+
1659
+ - `event.currentTarget` in the handler is that root, not the element. Find the element from `event.target` (`event.target.closest("li")`), or use the loop index the handler receives (`removeItem(event, index)`).
1660
+ - `#stop` on an inner `on*:` binding stops the `on*:` handlers further out. It cannot stop a listener your own code added to an ancestor (`addEventListener` on a clickable card): that listener ran before the event reached the root.
1661
+ - Your code calling `stopPropagation()` on an ancestor (a modal's content keeping clicks from the overlay) keeps the delegated handlers inside it from running at all.
1662
+ - An element moved under another root (a dialog moved from a shadow root to `document.body`) no longer runs its delegated handlers.
1663
+ - Two handlers of one type on one element (`onclick: a; onclick: b`) both run; a `#stop` on the first does not stop the second, as in the DOM.
1664
+
1665
+ Other event types, and an event a custom element dispatches with `bubbles: false`, are heard on the element itself. Two-way bindings (`value:`, `checked:`, `radio:`, `checkbox:`) and `eventToken.` bindings keep their listener on the element.
1666
+
1667
+ ### `#direct`
1668
+
1669
+ `on*#direct:` attaches the listener to the element itself instead of delegating it: `currentTarget` is the element, `#stop` stops your own listeners on ancestors, an ancestor's `stopPropagation()` does not keep it from running, and it still runs after the element moves under another root. It combines with `#prevent` and `#stop`; inside `for:` / `if:` it is attached per row and removed with the row.
1670
+
1671
+ ```html
1672
+ <!-- a button inside a card whose click listener your own code added -->
1673
+ <button data-wcs="onclick#direct,stop: save">Save</button>
1674
+ ```
1675
+
1676
+ Handlers run where the DOM puts them. An outer `#direct` handler runs **before** the delegated handlers inside it, because those run at the root, after the event has passed the outer element. So an inner `#stop` cannot stop an outer `#direct` handler unless the inner binding is `#direct` too (`onclick#direct,stop:`). `#direct` on a binding that is not an event binding does nothing (`@wcstack/lint` warns about it).
1677
+
1527
1678
  ## Filters
1528
1679
 
1529
- 48 built-in filters are available for both input (DOM โ†’ state) and output (state โ†’ DOM) directions.
1680
+ 47 built-in filters are available for both input (DOM โ†’ state) and output (state โ†’ DOM) directions: 24 logic filters in the core (comparison, arithmetic, type conversion, boolean / default) and 23 formatting filters in the `formats` add-on (number formatting, string, date / time), which the full entries include.
1530
1681
 
1531
- **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.
1682
+ The names 3.x kept as aliases of the 3.2 renames (`inc`, `dec`, `fix`, `uc`, `lc`, `cap`, `rep`, `rev`, `pad`, `null`) and the `substr` filter do not exist: they fail like any unknown name, and the message names what to write instead (`"uc" was renamed "upper" in 3.2 and removed in 4.0 โ€” write "upper".`; `substr(start, length)` is `slice(start, start + length)`).
1532
1683
 
1533
1684
  ### Comparison
1534
1685
 
@@ -1536,7 +1687,7 @@ export default {
1536
1687
  |---|---|---|
1537
1688
  | `eq(value)` | Equal | `count\|eq(0)` โ†’ `true/false` |
1538
1689
  | `ne(value)` | Not equal | `count\|ne(0)` |
1539
- | `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` |
1690
+ | `not` | Invert truthiness โ€” `0`, `""`, `null` and `undefined` all give `true` | `isActive\|not` |
1540
1691
  | `lt(n)` | Less than | `count\|lt(10)` |
1541
1692
  | `le(n)` | Less than or equal | `count\|le(10)` |
1542
1693
  | `gt(n)` | Greater than | `count\|gt(0)` |
@@ -1546,8 +1697,8 @@ export default {
1546
1697
 
1547
1698
  | Filter | Description | Example |
1548
1699
  |---|---|---|
1549
- | `add(n)` (`inc`) | Add | `count\|add(1)` |
1550
- | `sub(n)` (`dec`) | Subtract | `count\|sub(1)` |
1700
+ | `add(n)` | Add | `count\|add(1)` |
1701
+ | `sub(n)` | Subtract | `count\|sub(1)` |
1551
1702
  | `mul(n)` | Multiply | `price\|mul(1.1)` |
1552
1703
  | `div(n)` | Divide | `total\|div(100)` |
1553
1704
  | `mod(n)` | Modulo | `index\|mod(2)` |
@@ -1558,7 +1709,7 @@ export default {
1558
1709
 
1559
1710
  | Filter | Description | Example |
1560
1711
  |---|---|---|
1561
- | `toFixed(n)` (`fix`) | Fixed decimal places | `price\|toFixed(2)` โ†’ `"100.00"` |
1712
+ | `toFixed(n?)` | Fixed decimal places (default 0) | `price\|toFixed(2)` โ†’ `"100.00"` |
1562
1713
  | `round(n?)` | Round | `value\|round(2)` |
1563
1714
  | `floor(n?)` | Floor | `value\|floor` |
1564
1715
  | `ceil(n?)` | Ceiling | `value\|ceil` |
@@ -1570,16 +1721,15 @@ export default {
1570
1721
 
1571
1722
  | Filter | Description | Example |
1572
1723
  |---|---|---|
1573
- | `upper` (`uc`) | Upper case | `name\|upper` |
1574
- | `lower` (`lc`) | Lower case | `name\|lower` |
1575
- | `capitalize` (`cap`) | Capitalize | `name\|capitalize` |
1724
+ | `upper` | Upper case | `name\|upper` |
1725
+ | `lower` | Lower case | `name\|lower` |
1726
+ | `capitalize` | Capitalize | `name\|capitalize` |
1576
1727
  | `trim` | Trim whitespace | `text\|trim` |
1577
- | `slice(n)` | Slice string | `text\|slice(5)` |
1578
- | `substr(start, length)` | Substring (both arguments required). Warns in 3.5 and is removed in 4.0: write `slice(start, start + length)` โ€” [Preparing for 4.0](#preparing-for-40-wcsv4-migration) | `text\|substr(0,10)` |
1579
- | `padStart(n, char?)` (`pad`) | Pad the start (default `0`) | `id\|padStart(5,0)` โ†’ `"00001"` |
1580
- | `padEnd(n, char?)` | Pad the end (default a space; 3.2) | `code\|padEnd(8)` |
1581
- | `repeat(n)` (`rep`) | Repeat | `text\|repeat(3)` |
1582
- | `reverse` (`rev`) | Reverse | `text\|reverse` |
1728
+ | `slice(start, end?)` | Slice a string (`end` is an index, not a length) | `text\|slice(5)` / `code\|slice(2,5)` |
1729
+ | `padStart(n, char?)` | Pad the start (default `0`) | `id\|padStart(5,0)` โ†’ `"00001"` |
1730
+ | `padEnd(n, char?)` | Pad the end (default a space) | `code\|padEnd(8)` |
1731
+ | `repeat(n)` | Repeat | `text\|repeat(3)` |
1732
+ | `reverse` | Reverse | `text\|reverse` |
1583
1733
  | `truncate(n, suffix?)` | Shorten and append an ellipsis (default `โ€ฆ`, one U+2026 character) | `title\|truncate(20)` |
1584
1734
  | `join(sep?)` | Join an array (default `", "`) | `tags\|join` / `tags\|join(/)` |
1585
1735
 
@@ -1592,7 +1742,7 @@ export default {
1592
1742
  | `boolean` | To boolean | `value\|boolean` |
1593
1743
  | `number` | To number | `value\|number` |
1594
1744
  | `string` | To string | `value\|string` |
1595
- | `nullIfEmpty` (`null`) | Turn an empty string into `null` (anything else passes through) | `value\|nullIfEmpty` |
1745
+ | `nullIfEmpty` | Turn an empty string into `null` (anything else passes through) | `value\|nullIfEmpty` |
1596
1746
 
1597
1747
  ### Date / Time
1598
1748
 
@@ -1604,16 +1754,16 @@ export default {
1604
1754
  | `ymd(sep?)` | YYYY-MM-DD (default `-`) | `createdAt\|ymd` / `createdAt\|ymd(/)` |
1605
1755
  | `hms(sep?)` | HH:MM:SS (default `:`) | `createdAt\|hms` / `createdAt\|hms(-)` |
1606
1756
 
1607
- **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).
1757
+ **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 the page locale ([Locale](#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).
1608
1758
 
1609
1759
  ### Boolean / Default
1610
1760
 
1611
1761
  | Filter | Description | Example |
1612
1762
  |---|---|---|
1613
- | `truthy` | Truthy check โ€” JavaScript's own truthiness, the same as `boolean` (so `0n` is falsy since 3.0) | `value\|truthy` |
1763
+ | `truthy` | Truthy check โ€” JavaScript's own truthiness, the same as `boolean` (so `0n` is falsy) | `value\|truthy` |
1614
1764
  | `falsy` | Falsy check (JavaScript's truthiness; `defaults` uses the same test) | `value\|falsy` |
1615
1765
  | `defaults(v)` | Fallback for any falsy value (`0`, `false` and `""` included) | `name\|defaults(Anonymous)` |
1616
- | `coalesce(v)` | Fallback for `null` / `undefined` only (`0`, `false` and `""` stay; 3.2) | `count\|coalesce(0)` |
1766
+ | `coalesce(v)` | Fallback for `null` / `undefined` only (`0`, `false` and `""` stay) | `count\|coalesce(0)` |
1617
1767
 
1618
1768
  ### Filter Chaining
1619
1769
 
@@ -1623,9 +1773,9 @@ Filters can be chained with `|`:
1623
1773
  <div data-wcs="textContent: price|mul(1.1)|round(2)|locale(ja-JP)"></div>
1624
1774
  ```
1625
1775
 
1626
- 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.
1776
+ A filter is resolved when the bindings are planned. An unknown name throws `[wcs/filter-unknown]` (`#501`, with a did-you-mean from the `diagnostics` add-on), and 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. Either stops the `<wcs-state>`'s initialization ([Initialization failures](#initialization-failures)). Arguments are cached by their structure, so `join('a,b')` and `join(a)` are never confused.
1627
1777
 
1628
- **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`.
1778
+ **Argument literals are typed.** 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`, `eq('true')` does not, and `eq(null)` matches `null`. Numbers and strings compare by the value โ€” 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. 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`.
1629
1779
 
1630
1780
  **Quoting rules.** Quotes (`'` or `"`) mark a literal, and the argument grammar is deliberately small:
1631
1781
 
@@ -1643,10 +1793,10 @@ Many frameworks use patterns like prop drilling, context providers, or external
1643
1793
 
1644
1794
  1. The child references and updates the parent's state through its own state proxy โ€” no props, no events, no awareness of the parent.
1645
1795
  2. When the parent's state changes, the Proxy `set` trap automatically notifies any child bindings that reference the affected path.
1646
- 3. Because the only coupling is the **path name**, both sides stay loosely coupled. A Shadow DOM component also runs on its own ([standalone injection](#standalone-web-component-injection-e2esingle-component)); a Light DOM one does not โ€” the host has to wire it.
1647
- 4. The cost is path resolution (cached at O(1) after first access), change propagation through the dependency graph, and the per-row binding ledger the package builds for every row it renders.
1796
+ 3. Because the only coupling is the **path name**, both sides stay loosely coupled. A Shadow DOM component also runs on its own ([standalone injection](#standalone-web-component-injection-__e2e__single-component)); a Light DOM one does not โ€” the host has to wire it.
1797
+ 4. The cost is path resolution and change propagation through the dependency graph.
1648
1798
 
1649
- This is cross-component state management built on path resolution rather than on component-level abstractions. It is not the cheapest way to render: [Performance](#performance) puts create and append at 2.5โ€“3.5ร— [`@wcstack/signals`](../signals/), which is what the per-row ledger costs. What it buys is wiring that stays declarative and inspectable.
1799
+ This is cross-component state management built on path resolution rather than on component-level abstractions. What it buys is wiring that stays declarative and inspectable; what it costs is measured under [Performance](#performance).
1650
1800
 
1651
1801
  ### Component Definition (Shadow DOM)
1652
1802
 
@@ -1689,8 +1839,10 @@ customElements.define("my-light-component", MyLightComponent);
1689
1839
  - `<wcs-state>` must be a direct child of the component element
1690
1840
  - The host **must wire it** (`<my-light-component data-wcs="state.message: user.name">` or `state: user`) โ€” a plain, unwired Light DOM `bind-component` cannot exist in v2 (an independent tree cannot share the parent's root). It fails loudly with the migration guidance: attach a shadow root, or mount it from the host.
1691
1841
 
1692
- > **Note**: `State.getBindingsReady(root)` covers mounted scopes once the mount record resolves; await
1693
- > the component's own `<wcs-state>` initialization when you need its contents rendered.
1842
+ > **Note**: `getBindingsReady(shadowRoot)` on a Shadow DOM component waits for its `<wcs-state bind-component>`
1843
+ > until the component's bindings are built, and rejects if the mount fails. A Light DOM component binds
1844
+ > content in the page's root, which `getBindingsReady(document)` does not wait for: await the component's
1845
+ > own `<wcs-state>` (`initializePromise`, or `connectedCallbackPromise` to include `$connectedCallback`).
1694
1846
 
1695
1847
  ### Host Usage
1696
1848
 
@@ -1752,21 +1904,22 @@ customElements.define("user-card", UserCard);
1752
1904
  - Mounting an array as the root (`state: rows` with `for` over it inside) is not supported; mount the row (`state: .`) or the object that holds the array (`state: group` with `for: children` inside). Both forms are contract-tested; mounts are the only way to extend the tree.
1753
1905
 
1754
1906
  > The per-property form (`state.message: user.name`) keeps working โ€” it is a partial mount on
1755
- > the same machinery. **An explicit partial mount wins over the component's own key (3.0):** a
1907
+ > the same machinery. **An explicit partial mount wins over the component's own key:** a
1756
1908
  > component that declares a default for a mapped key (`state = { message: "" }` together with
1757
- > `state.message: ...`) reads the host value; the default is simply not used. (In 2.x R1 made
1758
- > that own key private and it hid the host value, with a `wcs/mount-own-key-shadow` warning.)
1909
+ > `state.message: ...`) reads the host value; the default is simply not used.
1759
1910
  > R1 still keeps every *unmapped* own key private. This is about a **one-segment** entry: a deeper
1760
1911
  > entry (`state.a.b: outer.b`) does not touch the privacy of `a`, so if the component declares `a`
1761
1912
  > itself, `a` and everything under it stay private and the entry never reaches it. The same holds
1762
1913
  > for an entry whose name is a getter, a setter or a method of the component โ€” the component's own
1763
1914
  > surface wins. Each of those shapes is reported once as `wcs/mount-own-key-shadow`; rename one side.
1915
+ > A mapping whose component-side path contains `*` (`state.list.*: items`) throws โ€” map the list
1916
+ > itself (`state.list: items`) โ€” and so does mapping one key twice.
1764
1917
  >
1765
- > **`#ro` on a mount is honoured (3.0):** `state#ro: user` or `state.title#ro: doc.title`
1918
+ > **`#ro` on a mount is honoured:** `state#ro: user` or `state.title#ro: doc.title`
1766
1919
  > lets the component read the entry but not write it โ€” `element.state.title = โ€ฆ`, `this.title = โ€ฆ`
1767
1920
  > in a method and `$setAll` / `$resolve` writes throw `[wcs/mount-readonly]`, and a two-way
1768
1921
  > binding inside the component (`value: title`) does not write back. The host can still write the
1769
- > path. (2.x accepted the modifier and ignored it.) The mounted `<wcs-state>` needs no `name`
1922
+ > path. The mounted `<wcs-state>` needs no `name`
1770
1923
  > in Light DOM, and `$getAll` / `$setAll` / `$resolve` / `$postUpdate` on `element.state`
1771
1924
  > (and on `this` inside getters/methods) speak the component's own vocabulary โ€” paths are
1772
1925
  > translated onto the mount and the host row's indexes are prepended automatically.
@@ -1786,7 +1939,7 @@ A mounted component's getters are **exported** at the mount point: **a read of a
1786
1939
  - 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.
1787
1940
  - 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.
1788
1941
  - 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.
1789
- - **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).
1942
+ - **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 dependency graph 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).
1790
1943
 
1791
1944
  ### Standalone Web Component Injection (`__e2e__/single-component`)
1792
1945
 
@@ -1827,7 +1980,11 @@ customElements.define("my-component", MyComponent);
1827
1980
  - `<wcs-state>` with `bind-component` must be a **direct child** of the component element (top-level)
1828
1981
  - The parent element must be a **custom element** (tag name containing a hyphen)
1829
1982
  - Light DOM components must be wired from the host (the plain, unwired form was removed in v2)
1830
- - 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
1983
+ - The component's state is the host element's property: a `<wcs-state bind-component>` with `state`, `src`, `json` or an inline script is refused, and so is a second connected `<wcs-state bind-component>` in one component (`<tag> already has a connected <wcs-state bind-component="state">.`)
1984
+ - A **mounted** scope runs `$listKeys`, `$commandTokens` / `$eventTokens` / `$on` and `$errorCallback` on its own bindings and lists, and its own `$behavior` / `$features`. It does not run `$watch`, `$stream` and `$renderedCallback` (one `wcs/mount-dollar-declaration` warning), and `$recursion` / `**` getters throw `[wcs/mount-dollar-declaration]`. Declare those on the root state. An unwired Shadow DOM child owns an independent tree and can declare all of them
1985
+ - A host wiring added after the component loaded its state (`state.a: x` bound later) cannot reach it, and throws โ€” bind the host's wiring before the component loads
1986
+ - A component whose mount fails rejects its `connectedCallbackPromise` โ€” including a component wired to a root that failed to initialize (`<tag>.state will not mount: the root state failed to initialize.`) โ€” see [Initialization failures](#initialization-failures)
1987
+ - When a component replaces its `<wcs-state bind-component>` with a new one while nodes the old one bound or rendered are still there, the new element takes the scope over: those bindings, rows and `{{ }}` text stay as they are, and nodes with bindings added beside them are not bound (a `console.warn` says so). When the content is rendered again with it, the new element binds it afresh
1831
1988
 
1832
1989
  ### Loop with Components
1833
1990
 
@@ -1842,7 +1999,7 @@ customElements.define("my-component", MyComponent);
1842
1999
  </template>
1843
2000
  ```
1844
2001
 
1845
- A component in a row reads and writes its own row. The `this` of its `$connectedCallback` / `$disconnectedCallback` โ€” and a `this` kept from them in a timer or across an `await` โ€” follows the element's row: it keeps writing to that row while an `if:` hides the element, and moves to the new row when an element write (`users[1] = โ€ฆ`) replaces the row in place. Once the element has been taken out and reused for another row, a kept `this` throws `The host row of <user-row> was removed.` instead of writing to either row. When the row itself is removed, `$disconnectedCallback` can still read and write the component's own keys (clear the timer id it kept in `this.tid`); reading the row's keys (`this.name`) throws the same error. A partial mount (`state.name: .name`) keeps one set of own keys per element, not per row, so what its `$disconnectedCallback` writes there is still there when the element is reused for another row.
2002
+ A component in a row reads and writes its own row. The `this` of its `$connectedCallback` / `$disconnectedCallback` โ€” and a `this` kept from them in a timer or across an `await` โ€” follows the element's row: it keeps writing to that row while an `if:` hides the element. An element write of the host row (`this["users.1"] = obj`) replaces the value at that position: the component element stays, and so do its private keys, while what it reads of the row is the new value. When the row is removed, its component goes with it and renders no more: `$disconnectedCallback` can still read and write the component's own keys (clear the timer id it kept in `this.tid`), but reading or writing the row's keys (`this.name`) throws `The host row of <user-row> was removed.` instead of reaching another row.
1846
2003
 
1847
2004
  ### Rendering a List Inside the Component
1848
2005
 
@@ -1885,8 +2042,8 @@ Components can also be placed inside components, **stacking scopes**. An interme
1885
2042
  that only passes the array through โ€” running no `for:` of its own โ€” still lets a row-field write
1886
2043
  from the owning scope reach the rows at the bottom.
1887
2044
 
1888
- A component's author never has to know how deeply it is placed. `$1`, event-handler indexes,
1889
- `$renderedCallback` and `$getAll` all report positions **within the component's own scope**.
2045
+ A component's author never has to know how deeply it is placed. `$1`, event-handler indexes
2046
+ and `$getAll` all report positions **within the component's own scope**.
1890
2047
 
1891
2048
  ## Command Token (Method Binding)
1892
2049
 
@@ -1945,7 +2102,7 @@ export default {
1945
2102
  - Duplicate entries throw an error at initialization
1946
2103
  - The reserved name `$command` itself cannot appear in the array
1947
2104
  - Tokens are gathered under `$command` so they do not pollute the top-level state namespace; reactive properties with the same name as a token can coexist
1948
- - Accessing an undeclared name on `$command` (e.g. `this.$command.typo`) returns `undefined`. The typo then surfaces as a `TypeError` on the subsequent `.emit()` call, or โ€” when used as a binding right-hand side โ€” as a "requires a CommandToken value" error at binding time
2105
+ - Accessing an undeclared name on `$command` (e.g. `this.$command.typo`) returns `undefined`. The typo then surfaces as a `TypeError` on the subsequent `.emit()` call, or โ€” when used as a binding right-hand side โ€” as `[wcs/token-undeclared] "$command.typo" is not declared in $commandTokens.` (`#1302`, with a did-you-mean) when the binding is attached
1949
2106
 
1950
2107
  ### `command.<methodName>:` Binding
1951
2108
 
@@ -1978,13 +2135,15 @@ class MyFetcher extends HTMLElement {
1978
2135
  }
1979
2136
  ```
1980
2137
 
1981
- > **Breaking change since v1.9.1**: the `commands` field is now an array of `{ name, async? }` objects. The earlier `commands: ["fetch"]` plain-string form is no longer accepted โ€” bindings against such declarations throw `Command "<name>" is not declared in wcBindable.commands`. There is no legacy fallback; update the declaration to the object form.
2138
+ > The `commands` entries must be `{ name, async? }` objects. A plain-string entry (`commands: ["fetch"]`) declares nothing, so a binding to it throws `[wcs/token-misconfigured] <my-fetcher> declares no command "fetch".`
1982
2139
 
1983
2140
  Validation rules (enforced at binding time):
1984
2141
 
1985
2142
  - The element must be a custom element exposing `static wcBindable` with `protocol: "wc-bindable"` and an integer `version` of `1` or later (the current protocol version is `1`; all versions โ‰ฅ 1 are core-compatible)
1986
2143
  - `methodName` must appear (by `name`) in `wcBindable.commands`
1987
- - The bound value must be a `CommandToken` (assigning a non-token value throws โ€” for example, an undeclared name like `$command.typo` resolves to `undefined` and is rejected here)
2144
+ - `<tokenName>` must be declared in `$commandTokens` (`$command.typo` throws `[wcs/token-undeclared]`)
2145
+
2146
+ A custom element is checked once its class is defined; a native element is refused at once (`[wcs/token-misconfigured]`). A violation found while the page is bound fails the `<wcs-state>`'s initialization; one found inside a `for:` / `if:` row, or when a class is defined after the page was bound, fails that one binding, which goes to `$errorCallback`, and the row is still built ([Initialization failures](#initialization-failures)).
1988
2147
 
1989
2148
  ### Token API
1990
2149
 
@@ -2004,10 +2163,10 @@ interface CommandToken {
2004
2163
 
2005
2164
  ### Subscription Lifecycle
2006
2165
 
2007
- - The subscriber holds the element via `WeakRef`, so nothing in the token's subscriber set keeps the element alive **by itself**
2008
- - **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
2009
- - On `emit`, if the WeakRef has been collected or the element is no longer connected (`isConnected === false`), the subscription is purged automatically (lazy purge)
2010
- - 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`
2166
+ - The subscriber holds the element only through a `WeakRef`: the token never keeps the element alive. On `emit`, a subscriber whose element was collected unsubscribes itself
2167
+ - An element inside a `for:` row or an `if:` branch unsubscribes when its row or branch goes
2168
+ - An element that is not connected when the token is emitted (a hidden route whose nodes are kept) is not called, and stays subscribed: it receives the next `emit` once it is back in the document
2169
+ - Disconnecting the owning `<wcs-state>` keeps its tokens, so the subscriptions still receive commands after it is re-attached (for example when its host moves in the DOM). A re-set (`setInitialState()`) keeps the token of every name the new state declares again
2011
2170
 
2012
2171
  The element's method is invoked with the arguments from `emit`:
2013
2172
 
@@ -2146,7 +2305,7 @@ Validation rules:
2146
2305
 
2147
2306
  - The element must be a wc-bindable custom element (`static wcBindable`, `protocol: "wc-bindable"`, integer `version` โ‰ฅ 1 โ€” all versions โ‰ฅ 1 are core-compatible). A non-wc-bindable element is rejected at attach time.
2148
2307
  - `<property>` must appear in `wcBindable.properties` โ€” checked at **attach time** (fail-fast; needs only the class, not DOM connection).
2149
- - `<tokenName>` must be declared in `$eventTokens` โ€” checked at **fire time**. State is resolved from the element's live root node when the event fires, so the binding also works inside `for` / `if` blocks and after SSR hydration, where the node may still be detached at attach time.
2308
+ - `<tokenName>` must be declared in `$eventTokens` โ€” checked at **fire time**: the token is looked up when the event fires, so a re-set state's `$on` handlers receive it. An undeclared name is reported with `console.error` (`[wcs/token-undeclared]`, with a did-you-mean).
2150
2309
  - Modifiers `#prevent` / `#stop` work as on any event binding: `eventToken.error#prevent: createFailed`.
2151
2310
 
2152
2311
  ### Inside a Loop
@@ -2184,29 +2343,26 @@ $on: {
2184
2343
 
2185
2344
  ### Token API
2186
2345
 
2187
- Event tokens share the same `Token` pub/sub primitive as command tokens โ€” `name` / `size` / `subscribe` / `unsubscribe` / `emit`, with subscribe-order preservation (see [Token API](#token-api)). The token is resolved from the registry on every event so a re-`setInitialState()` rebuild still reaches the latest `$on` subscribers. Disconnecting the owning `<wcs-state>` keeps the event-token registry, so `$on` handlers (and `on` scans) receive events again once the root `<wcs-state>` is re-attached; an event dispatched while it is disconnected finds no state tree and is not delivered.
2346
+ Event tokens share the same `Token` pub/sub primitive as command tokens โ€” `name` / `size` / `subscribe` / `unsubscribe` / `emit`, with subscribe-order preservation (see [Token API](#token-api)). The token is resolved from the registry on every event so a re-`setInitialState()` rebuild still reaches the latest `$on` subscribers. Disconnecting the owning `<wcs-state>` keeps its event tokens and their `$on` handlers.
2188
2347
 
2189
2348
  ## Choosing a Time Mechanism
2190
2349
 
2191
- The next four sections answer four different questions, and the usual mistake is to reach for the wrong one. Choose by **what you are declaring**, not by where the data comes from:
2350
+ The next sections answer three different questions, and the usual mistake is to reach for the wrong one. Choose by **what you are declaring**, not by where the data comes from:
2192
2351
 
2193
2352
  | Declaration | What you declare | Owns a value | Fires | Typical use |
2194
2353
  |---|---|---|---|---|
2195
2354
  | [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 |
2196
2355
  | [`$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 |
2197
- | [`$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" |
2198
- | [`$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. Warns in 3.5 and is removed in 4.0: fold in a `$watch` (state paths) or `$on` (event tokens) โ€” [Preparing for 4.0](#preparing-for-40-wcsv4-migration) |
2356
+ | [`$watch`](#watch-watch) | A reaction to a change | No โ€” but its handler may write a key of its own | Once per batch per changed address | Side effects, "when this becomes true", accumulations over time |
2199
2357
 
2200
2358
  Two rules cut most of the confusion:
2201
2359
 
2202
2360
  - **`$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).
2203
- - **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`.
2361
+ - **A `$stream` fold resets on every restart.** When an accumulated value has to survive the restart, keep it as an ordinary key and write it from a `$watch` handler on the source path (or from an `$on` handler, to count events rather than states) โ€” a reset is a `$watch` handler that writes the initial value back. The value is then plain state that you own: a re-set replaces it like any other key.
2204
2362
 
2205
2363
  ## Streams (`$stream`)
2206
2364
 
2207
- > **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`.
2208
-
2209
- 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.
2365
+ 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. `$stream` is the `temporal` add-on (in the full entries). The 3.x name `$streams` throws `[wcs/declaration-alias] $streams was removed: write $stream.` (`#1601`).
2210
2366
 
2211
2367
  `$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.
2212
2368
 
@@ -2280,8 +2436,8 @@ Key rules:
2280
2436
  - **Cooperative cancellation (MUST)** โ€” `source` must observe the passed `AbortSignal` and stop producing when it fires. A `ReadableStream` source satisfies this automatically through its `cancel()` callback (the runtime cancels the reader on abort); only hand-rolled async iterables need to observe `signal` themselves.
2281
2437
  - **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.
2282
2438
  - **`args` is synchronous** โ€” returning a Promise is an error, and wildcard reads inside `args` are rejected.
2283
- - **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.
2284
- - **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`.)
2439
+ - **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 refused at declaration: when the sources yield in the task their run started in, the chain limit cuts the cycle (see [Watch](#watch-watch)); when the values arrive in later tasks (a network response, a message), each lap starts afresh and the cycle restarts forever โ€” do not build them. One-way chains (A's value feeding B's `args`) are legitimate.
2440
+ - **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. (Without that check the method would be silently overwritten by the start-time reset to `initial`.)
2285
2441
  - **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.
2286
2442
 
2287
2443
  See [docs/streams.md](docs/streams.md) for the full contract โ€” lifecycle and ownership, restart semantics, flush granularity, and the out-of-scope list.
@@ -2318,7 +2474,7 @@ $renderedCallback(paths) {
2318
2474
  }
2319
2475
  ```
2320
2476
 
2321
- **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". (`$scan` warns in 3.5 and is removed in 4.0; a `$watch` or an `$on` handler covers the same ground โ€” [Preparing for 4.0](#preparing-for-40-wcsv4-migration).)
2477
+ **The rule:** logic that must not depend on what is rendered belongs on a `$watch` or a `$stream` `args`. Keep `$renderedCallback` for "follow what was drawn".
2322
2478
 
2323
2479
  That example now folds each landed page into its feed from a `$watch` on the stream's value (and re-arms the sentinel from a `$watch` on the feed), 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`**.
2324
2480
 
@@ -2326,11 +2482,11 @@ That example now folds each landed page into its feed from a `$watch` on the str
2326
2482
 
2327
2483
  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.
2328
2484
 
2329
- 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.
2485
+ Note that a getter named in `$watch` becomes **eager**: evaluated once at connect, then at the end of every batch touching its dependencies. A wildcard row getter is evaluated for every row of its list, so watching one over a long list costs a walk of that list.
2330
2486
 
2331
2487
  ## Watch (`$watch`)
2332
2488
 
2333
- `$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.)
2489
+ `$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, row paths included. It is the `temporal` add-on (in the full entries).
2334
2490
 
2335
2491
  ```html
2336
2492
  <wcs-state>
@@ -2339,6 +2495,7 @@ Note that a scalar getter named in `$watch` becomes **eager** (evaluated once at
2339
2495
  isLoading: false,
2340
2496
  items: [],
2341
2497
  startedAt: 0,
2498
+ lastPriceChange: "",
2342
2499
 
2343
2500
  $watch: {
2344
2501
  // rising-edge detection: you compare cur/prev yourself
@@ -2347,7 +2504,6 @@ Note that a scalar getter named in `$watch` becomes **eager** (evaluated once at
2347
2504
  },
2348
2505
 
2349
2506
  // wildcard paths fire once per changed row
2350
- // (needs the list rendered with `for`, or `$listKeys` declared โ€” see below)
2351
2507
  "items.*.price"(cur, prev, index) {
2352
2508
  this.lastPriceChange = `#${index}: ${prev} โ†’ ${cur}`;
2353
2509
  },
@@ -2362,109 +2518,38 @@ The handler runs with `this` bound to a **writable** state proxy, so it can writ
2362
2518
  | Argument | Contract |
2363
2519
  |---|---|
2364
2520
  | `cur` | The value at drain time (the settled value for the batch) |
2365
- | `prev` | The value at the **start of the batch** (first-write-wins). Recorded **only when a primitive is written** (the value before may be an object) โ€” see below |
2521
+ | `prev` | The value at the **start of the batch** (first-write-wins). Recorded **only when a primitive is written** to the watched path itself (the value before may be an object) โ€” see below. For a getter: its previous evaluation |
2366
2522
  | `...indexes` | Only for wildcard paths: this scope's own loop indexes, same convention as `$1`, `$2` |
2367
2523
 
2368
- **`prev` comes only with primitive writes.** It reuses the old value the same-value guard reads before writing a primitive, so watch costs no extra read โ€” and it is `undefined` when the new value is a reference type (an in-place mutation would give you the same reference anyway), for `$postUpdate`, and when `config.sameValueGuard` is off. A primitive written over an object passes that object as `prev`.
2524
+ **`prev` comes only with primitive writes.** It reuses the old value the same-value guard reads before writing a primitive, so watch costs no extra read โ€” and it is `undefined` when the new value is a reference type (an in-place mutation would give you the same reference anyway), when an object above the path was written, for `$postUpdate`, for a row that entered the list, and when `$behavior.sameValueGuard` is off. A primitive written over an object passes that object as `prev`.
2369
2525
 
2370
- **Watch adds no firing condition of its own.** It fires for whatever landed in the update batch. That falls out well: an equal primitive write is already dropped before it is enqueued (so you effectively get change-only firing), while an occurrence write โ€” a `semantics: "event"` property โ€” is deliberately *not* dropped, and still fires with `cur === prev`. If you need edge detection, compare `cur` and `prev` in the handler.
2526
+ **Watch adds almost no firing condition of its own.** It fires for whatever landed in the update batch: an equal primitive write is already dropped before it is enqueued (so you effectively get change-only firing), while an occurrence write โ€” a `semantics: "event"` property โ€” is deliberately *not* dropped, and still fires with `cur === prev`. Two cases compare values: a key with a numeric index (`"items.0.v"`) fires only when the value at that index changed, and a row getter (`items.*.label`) on a row that a list assignment or re-sort kept fires only when its value changed โ€” unless a write in the same batch changed something the getter read outside its row (`now`, `items.length`, a root getter that reads them) or wrote the same path in another row. If you need edge detection, compare `cur` and `prev` in the handler.
2371
2527
 
2372
- **Watching a getter makes it eager.** A computed getter is normally lazy, and its dependencies are only recorded when it is evaluated โ€” so an unrendered getter would never fire at all. Declaring one in `$watch` evaluates it once at connect and again at the end of every batch that touches its dependencies. Its `prev` is the previous evaluation. Watch a heavy computed and you pay that evaluation on every batch; exceptions inside it surface through the watch instead of staying dormant. Wildcard getters (`items.*.tax`) are **not** made eager โ€” priming one would sweep the whole list โ€” so that form fires only when it is also bound to the DOM, and its `prev` is always `undefined` (no per-row evaluation is remembered).
2528
+ **Watching a getter makes it eager.** A computed getter is normally lazy, and its dependencies are only recorded when it is evaluated โ€” so an unrendered getter would never fire at all. Declaring one in `$watch` evaluates it once at connect โ€” for every row, when it is a wildcard getter (`items.*.tax`) โ€” and again at the end of every batch that touches its dependencies. Its `prev` is the previous evaluation, and a change that reaches it fires the watch whether or not the value changed (but for the two cases above). Watch a heavy computed and you pay that evaluation on every batch; exceptions inside it surface through the watch instead of staying dormant. An evaluation at connect that throws is reported (`$watch initial evaluation of "โ€ฆ" threw.`), that watch's first `prev` is `undefined`, and everything else goes on.
2373
2529
 
2374
2530
  Firing order is defined in three layers, and only the middle one is yours to steer:
2375
2531
 
2376
2532
  | Layer | Order | Your control |
2377
2533
  |---|---|---|
2378
- | Mechanisms | `$renderedCallback` โ†’ `$scan` โ†’ `$watch` โ†’ `$stream` restart | fixed |
2534
+ | Mechanisms | `$renderedCallback` โ†’ `$watch` โ†’ `$stream` restart | fixed |
2379
2535
  | Between handlers | declaration order in `$watch` | **reorder the declarations** |
2380
2536
  | Between rows of one path | ascending `indexes` | fixed |
2381
2537
 
2382
- **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.
2538
+ **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 `$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 `$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.
2383
2539
 
2384
2540
  Key rules:
2385
2541
 
2386
- - **Paths of the tree only** โ€” a path may not contain `@` (the v1 name selector); such a declaration is rejected loudly.
2542
+ - **Paths of the tree only** โ€” a key may not be empty, start with `$`, contain `@` (the v1 name selector), or name an `Object.prototype` member (`toString`); such a declaration throws (`$watch path "โ€ฆ" is not a path of the state tree.`). A key with `**` throws `[wcs/recursion-unsupported]`. A key that does not resolve on the state is warned about by the `diagnostics` add-on (`wcs/watch-path-missing`).
2387
2543
  - **Intermediate values are not observable** โ€” a batch that goes `a โ†’ b โ†’ c` fires once with `cur = c`, `prev = a`, the same contract as binding updates.
2388
- - **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.
2389
- - **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.
2390
- - **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. Without a `for`, reassigning the same array or a copy of it and `$postUpdate("items")` do not fire it either: they only make the row values be read again, so a getter over them (`$getAll("items.*.price", [])`) sees values changed in place.
2391
- - **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.
2392
- - **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. Links are counted per write: a handler (or a `$scan` fold) starts from the depth of the write that triggered it, so only a handler's write that triggers another handler extends the chain. A handler that follows a long finite render chain โ€” `$renderedCallback` shrinking a heading one step per render โ€” is not cut off, even when its own writes ride in the same batches. A `$stream` restart counts like a handler: its writes (the reset to `initial`, the status) continue the chain of the write that triggered it, and a restart triggered by a write past the limit is not run. So a `$watch` that writes a stream's `args` and is woken again by the restart is cut off, and so are streams whose `args` read each other โ€” the batch past the limit is reported even when no `$watch` is declared.
2393
- - **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.
2544
+ - **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. Assigning an array fires for the rows that entered the list; a row kept by identity did not change and does not fire.
2545
+ - **Row-level diffs want `$listKeys`** โ€” data that arrives as fresh objects (a refetch) enters as all-new rows, so the row watch fires for *every* row with `prev === undefined`. With `$listKeys` declared, the key match decomposes the assignment into per-field writes, so only changed rows fire and `prev` is a real scalar.
2546
+ - **A row watch is headless too** โ€” it needs no `for:` and no `$listKeys`: the watch keeps the lists it ranges over in step itself. Reassigning the same array or a copy of it, and `$postUpdate("items")`, do not fire it โ€” no row entered the list and no row path was written โ€” but a getter over the rows (`$getAll("items.*.price", [])`) sees values changed in place.
2547
+ - **Shared arrays fire per position** โ€” when several outer rows hold the same array, writing one of its elements fires `$watch("groups.*.items.*")` once for every outer row that holds it, each with its own indexes: the value changed at every one of those paths.
2548
+ - **Handler exceptions are isolated** โ€” a throw is reported (`$watch "โ€ฆ" failed; the other watches still run.`) and the remaining watches (and stream restarts) still run.
2549
+ - **Write chains are bounded** โ€” a handler's writes form a new batch, so mutually-writing watches would loop forever. Each write carries a chain depth: one more than that of the write that fired the handler making it. A handler fired more than 32 deep does not run: the chain is cut, reported once with `console.error` (`$watch handlers / $stream restarts kept writing for 32 batches; the chain is cut (nothing is rolled back).`) and to DevTools as `state:watch-chain-limit`, while the batch's other handlers still run. Values and DOM are not rolled back. Writes riding in the same batch โ€” a render's write-back โ€” neither lengthen nor reset a chain, so a handler that follows a long finite render chain โ€” `$renderedCallback` shrinking a heading one step per render โ€” is not cut off. A `$stream` restart counts like a handler: its writes (the reset to `initial`, the status) continue the chain of the write that reached its `args`, and a restart past the limit is not made. A value a source yields in the same task as its run's (re)start counts toward that run's chain, so two streams whose `args` read each other's values with sources that yield at once are cut instead of freezing the page. A value from a later task, and the handlers it fires, start afresh.
2550
+ - **Not run on a mounted `bind-component` scope** โ€” the `$watch` declaration of a mounted component is ignored with a one-time `wcs/mount-dollar-declaration` warning that points to the root state; this applies to `$stream` and `$renderedCallback` too. A volume refuses `$watch` ([`mount=`](#mounting-additional-state-mount)): declare it on the root with the full path (`"cart.total"`). A plain (unwired Shadow) child owns an independent tree and can declare it.
2394
2551
  - **SSR does not run watches** โ€” handler side effects would otherwise execute on both server and client.
2395
2552
 
2396
- ## Scan (`$scan`)
2397
-
2398
- > **`$scan` warns in 3.5 and is removed in 4.0.** Fold a state path from a `$watch` handler that writes the accumulated value, and an event token from an `$on` handler; keep the reset condition in the handler. See [Preparing for 4.0](#preparing-for-40-wcsv4-migration).
2399
-
2400
- `$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.
2401
-
2402
- ```html
2403
- <wcs-state>
2404
- <script type="module">
2405
- export default {
2406
- page: 1,
2407
- host: "a",
2408
- $eventTokens: ["message"],
2409
- $stream: {
2410
- pageResult: { args: (s) => s.page, source: loadPage },
2411
- },
2412
- $scan: {
2413
- // from: fold each landing of a state path โ€” here, the stream's value
2414
- feed: {
2415
- from: "pageResult",
2416
- initial: { items: [], pages: [] },
2417
- fold: (feed, chunk) =>
2418
- chunk?.kind === "success" && !feed.pages.includes(chunk.page)
2419
- ? { items: feed.items.concat(chunk.items), pages: [...feed.pages, chunk.page] }
2420
- : feed,
2421
- },
2422
- // on: fold each event of a declared event token
2423
- log: {
2424
- on: "message",
2425
- initial: [],
2426
- fold: (log, event) => [...log.slice(-49), event.detail],
2427
- resetOn: ["host"], // back to [] whenever host changes
2428
- },
2429
- },
2430
- };
2431
- </script>
2432
- </wcs-state>
2433
-
2434
- <template data-wcs="for: feed.items">โ€ฆ</template>
2435
- ```
2436
-
2437
- | Field | Contract |
2438
- |---|---|
2439
- | `from` | A state path. Wildcards are allowed; it may not start with `$`, and may not be a getter or sit under one. Declare exactly one of `from` / `on`. |
2440
- | `on` | An event-token name declared in `$eventTokens`. |
2441
- | `initial` | Required. The seed of the accumulator, and what `resetOn` returns to. |
2442
- | `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. |
2443
- | `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. |
2444
-
2445
- **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.
2446
-
2447
- How the two sources fire:
2448
-
2449
- | | `from` (a path) | `on` (an event token) |
2450
- |---|---|---|
2451
- | Unit | One fold per address that landed in an update batch. Writes made in one job are coalesced. | One fold per event. Two events in one task fold twice. |
2452
- | When | At the end of the drain, before `$watch`. | Inside the event, before that token's `$on` handlers. |
2453
- | Output visible | From the next batch. A `$watch` on the output fires then, normally with `prev === undefined` (see below). | Immediately. The `$on` handlers of the same event already see it. |
2454
-
2455
- Key rules:
2456
-
2457
- - **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`).
2458
- - **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.
2459
- - **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.
2460
- - **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`.
2461
- - **Keep folds bounded.** An infinite source must fold into a bounded value (the last N, a count), exactly as with `$stream`.
2462
- - **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.
2463
- - **`$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`.
2464
- - **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.
2465
-
2466
- Reference: [docs/scan.md](https://github.com/wcstack/wcstack/blob/main/packages/state/docs/scan.md). Design record: [docs/state-scan-design.md](https://github.com/wcstack/wcstack/blob/main/docs/state-scan-design.md).
2467
-
2468
2553
  ## Inputs and Attribute Mirror
2469
2554
 
2470
2555
  `wcBindable.inputs` declares one-way property inputs (state โ†’ element). When an entry sets `attribute`, the framework writes the value to that HTML attribute every time it writes the property, so `attributeChangedCallback`, CSS attribute selectors, and DevTools all stay in sync with the property value.
@@ -2508,7 +2593,7 @@ Attribute value encoding:
2508
2593
  Notes:
2509
2594
 
2510
2595
  - `inputs` entries **without** `attribute` are property-only โ€” the value is written to the property but no attribute is touched
2511
- - Mirror is best-effort: a `setAttribute` failure is swallowed (with a `debug` warning) and does not block the property write
2596
+ - Mirror is best-effort: a `setAttribute` failure is swallowed and does not block the property write
2512
2597
  - Native HTML elements ignore `inputs` entirely โ€” the mirror only activates for custom elements that expose `static wcBindable`
2513
2598
 
2514
2599
  ## Choosing a Component Mechanism
@@ -2590,7 +2675,7 @@ This generates:
2590
2675
 
2591
2676
  - `static wcBindable` on the class โ€” protocol metadata for framework adapters. Each `$bindables` member is declared in both `properties` and `inputs` (two-way), so parent-state โ†’ DCC writes keep working under directional initial sync โ€” see [Binding Authority](#binding-authority-init--sync). Each `$commands` member becomes a `commands` entry
2592
2677
  - Getter/setter on the prototype for `$bindables`, a method for `$commands` โ€” both go through the reactive proxy
2593
- - `CustomEvent` dispatch โ€” `my-counter:count-changed` fires on every mutation of a `$bindables` member
2678
+ - `CustomEvent` dispatch โ€” `my-counter:count-changed` fires on every mutation of a `$bindables` member (a write of the member, of a path under it, or its `$postUpdate`); `detail` carries the written value only for a write of the member itself, and the declared `getter` reads the member off the element
2594
2679
 
2595
2680
  `commands` entries are always declared `async: true`. A DCC method chains on the inner `<wcs-state>`'s initialization, so it returns a Promise whether or not the state method itself was written `async`.
2596
2681
 
@@ -2660,7 +2745,7 @@ Properties prefixed with `$` are internal and not exposed on the component proto
2660
2745
  | `$commands` | Declares invocable methods |
2661
2746
  | `$connectedCallback` | Lifecycle hook (runs on each instance) |
2662
2747
  | `$disconnectedCallback` | Cleanup hook |
2663
- | `$renderedCallback` | Called after state mutations |
2748
+ | `$renderedCallback` | Called after bindings are applied |
2664
2749
 
2665
2750
  ## SVG Support
2666
2751
 
@@ -2678,7 +2763,7 @@ All bindings work inside `<svg>` elements. Use `attr.*` for SVG attributes:
2678
2763
 
2679
2764
  State objects can define `$connectedCallback`, `$disconnectedCallback`, `$renderedCallback`, and `$errorCallback` for initialization, cleanup, update, and binding-failure handling.
2680
2765
 
2681
- > **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`.
2766
+ > `$renderedCallback` receives updates of **applied bindings**, not every state update (use `$watch` for those). The 3.x name `$updatedCallback` throws `[wcs/declaration-alias] $updatedCallback was removed: write $renderedCallback.` (`#1601`).
2682
2767
 
2683
2768
  ```html
2684
2769
  <wcs-state>
@@ -2705,7 +2790,7 @@ State objects can define `$connectedCallback`, `$disconnectedCallback`, `$render
2705
2790
 
2706
2791
  | Hook | Timing | Async |
2707
2792
  |---|---|---|
2708
- | `$connectedCallback` | After state initialization on first connect; on every reconnect thereafter | Yes (awaited) |
2793
+ | `$connectedCallback` | Once the state is loaded and the bindings are built, on first connect; on every reconnect thereafter | Yes (awaited) |
2709
2794
  | `$disconnectedCallback` | When the element is removed from the DOM | No (sync only) |
2710
2795
  | `$renderedCallback(paths, indexesListByPath)` | After updates are applied to live bindings | Yes (not awaited) |
2711
2796
  | `$errorCallback(error, info)` | After a drain in which a binding failed to apply โ€” once per failed binding, after `$renderedCallback` | Yes (not awaited) |
@@ -2715,8 +2800,8 @@ All hooks except `$disconnectedCallback` support `async` โ€” you can use `async/
2715
2800
  - `this` inside hooks is the state proxy with full read/write access
2716
2801
  - `$connectedCallback` is called **every time** the element is connected (including re-insertion after removal), making it suitable for setup that should be re-established
2717
2802
  - `$disconnectedCallback` is called synchronously โ€” use it for cleanup such as clearing timers, removing event listeners, or releasing resources
2718
- - `$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
2719
- - `$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:
2803
+ - `$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. A mounted component's bindings, and so its private keys, never appear in the host's `paths`. Can be `async`, but the return value is not awaited; one that throws or rejects is reported with `console.error`
2804
+ - `$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; `node` is `null` for a list that no `for:` renders โ€” one only `$getAll` or `$watch` keeps). A binding refused as it is attached inside a `for:` / `if:` row (an undeclared token, a member the element does not declare) is reported here too, as that binding's failure, and the row is still built. `this` is the writable state proxy, so the usual shape is to write the message into state and render it like anything else:
2720
2805
 
2721
2806
  ```js
2722
2807
  export default {
@@ -2728,7 +2813,7 @@ All hooks except `$disconnectedCallback` support `async` โ€” you can use `async/
2728
2813
  };
2729
2814
  ```
2730
2815
 
2731
- 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).
2816
+ 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. A volume (`<wcs-state mount>`) does not run it (`console.warn`); a mounted component's receives the failures of the component's own bindings. It does not cover `$watch` handlers (isolated and reported separately), a `$renderedCallback` that throws (reported with `console.error`), or a `$connectedCallback` that fails ([Initialization failures](#initialization-failures)).
2732
2817
  - In Web Components, define `async $stateReadyCallback(stateProp)` to receive a hook when the bound state becomes available via `bind-component`
2733
2818
 
2734
2819
  ## Transition animations
@@ -2752,15 +2837,135 @@ li {
2752
2837
  Two consequences to know while that tag accepts the `state` participant:
2753
2838
 
2754
2839
  - 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.
2755
- - Because `$scan`, `$watch` and the `$stream` restart stay on the original microtask, they now run **before** `$renderedCallback` instead of after it.
2840
+ - Because `$watch` and the `$stream` restart stay on the original microtask, they run **before** `$renderedCallback` instead of after it.
2756
2841
 
2757
2842
  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.
2758
2843
 
2759
2844
  ## Diagnostics and failure handling
2760
2845
 
2846
+ ### Error messages and numbers
2847
+
2848
+ Every message starts with `[@wcstack/state]`. A message about something `@wcstack/lint` and the VS Code extension also check carries the same code (`[wcs/filter-unknown]`), and most messages carry a number (`#501`). The hundreds of a number name its code โ€” 1xx `binding-syntax`, 2xx `template-syntax`, 3xx `binding-path-missing`, 4xx `binding-type-expectation`, 5xx `filter-unknown`, 6xx `filter-arity`, 7xx `getter-cycle`, 8xx `getter-depth-exceeded`, 9xx `index-arity`, 10xx `index-param-range`, 11xx `recursion-unsupported`, 12xx `token-misconfigured`, 13xx `token-undeclared`, 14xx `wildcard-rank`, 15xx `spread-no-bindable`, 16xx `declaration-alias`, 17xx `name-alias`; 1โ€“99 carry no code. Numbers are only ever added, so a number keeps its meaning across versions.
2849
+
2850
+ The sentence of a numbered message โ€” with the nearest name (did-you-mean, by the same rule as the lint), how to fix it, and a pointer to the lint where the lint detects the case โ€” comes from the `diagnostics` add-on, which `@wcstack/state` and `/auto` include. On a `/core` page without it, a message is its code, its number and the values it would show:
2851
+
2852
+ ```
2853
+ [@wcstack/state] [wcs/filter-unknown] filter not found: uc. "uc" was renamed "upper" in 3.2 and removed in 4.0 โ€” write "upper". Validate statically: npx @wcstack/lint <file>.
2854
+ [@wcstack/state] [wcs/filter-unknown] #501 "uc"
2855
+ ```
2856
+
2857
+ The first line is what `@wcstack/state` and `/auto` print, the second what `/core` without `features/diagnostics` prints. Install `diagnostics` while developing on `/core`. The barriers a page without add-ons meets on purpose (`[wcs/feature-not-installed]`, a formatting filter without `formats`) are full sentences in the core.
2858
+
2859
+ <details>
2860
+ <summary>The numbered messages</summary>
2861
+
2862
+ | # | Code | Message (with `diagnostics`) |
2863
+ |---|---|---|
2864
+ | 1 | | `$scan was removed (use $watch or $on)` |
2865
+ | 2 | | `"<path>" is a getter without a setter` |
2866
+ | 3 | | `no row for "<path>"` |
2867
+ | 4 | | `cannot write "<path>": its parent is <value>` |
2868
+ | 5 | | `"<name>" is not a method` |
2869
+ | 6 | | `$getAll("<path>"): no loop level in common with the context` |
2870
+ | 7 | | `$eqIndex("<path>") needs a list row scope.` |
2871
+ | 8 | | `This state is readonly.` |
2872
+ | 9 | | `$setAll("<path>") needs indexes ([] for every match)` |
2873
+ | 10 | | `$setAll("<path>", โ€ฆ, { spread: true }) needs an array of <n> values` |
2874
+ | 11 | | `updates did not settle after 32 passes` |
2875
+ | 12 | | `binding "<type>: <path>" failed to apply.` |
2876
+ | 13 | | `failed to load "<src>": <status>` |
2877
+ | 14 | | `this <wcs-state> failed to initialize; create a new one` |
2878
+ | 15 | | `state is not initialized` |
2879
+ | 16 | | `no <script> with id "<id>"` |
2880
+ | 17 | | `a subscriber of token "<name>" threw.` |
2881
+ | 18 | | `<key> must be an array of strings.` |
2882
+ | 19 | | `<key> entries must be non-empty strings.` |
2883
+ | 20 | | `<key> entry "<name>" conflicts with the reserved namespace name "<reserved>".` |
2884
+ | 21 | | `<key> entry "<name>" is duplicated.` |
2885
+ | 22 | | `$on must be an object of handlers.` |
2886
+ | 23 | | `$on entry "<name>" is not declared in $eventTokens.` |
2887
+ | 24 | | `$on entry "<name>" must be a function.` |
2888
+ | 25 | | `view-transition naming-limit (<limit>) reached.` |
2889
+ | 26 | | `filter <name> requires at least one option` |
2890
+ | 27 | | `filter <name> requires a number as option` |
2891
+ | 28 | | `filter <name> requires a number value` |
2892
+ | 29 | | `filter <name> requires a date value` |
2893
+ | 30 | | `filter <name> requires an array value` |
2894
+ | 31 | | `init=/sync= modifiers require enableDirectionalInitialSync.` |
2895
+ | 32 | | `"<text>": the "@name" selector was removed in v2 โ€” there is a single state tree. โ€ฆ` |
2896
+ | 33 | | `Unknown binding modifier "<key>" in "<modifier>".` |
2897
+ | 34 | | `Binding modifier "<key>" may only be specified once.` |
2898
+ | 35 | | `Invalid <key> modifier value "<value>".` |
2899
+ | 36 | | `Event bindings only allow init=none.` |
2900
+ | 37 | | `Binding type "<type>" does not support init=<init>.` |
2901
+ | 38 | | `Property "<name>" is not declared by wcBindable.` |
2902
+ | 39 | | `init=<init> is incompatible with wcBindable member "<name>".` |
2903
+ | 40 | | `sync=connect requires observable property "<name>".` |
2904
+ | 41 | | `render chain depth limit exceeded (100 drains that rendering itself started); bindings for this batch were not applied.` |
2905
+ | 42 | | `The inline <script> of <wcs-state> was blocked by Content-Security-Policy. โ€ฆ` |
2906
+ | 43 | | `Failed to evaluate the inline <script> of <wcs-state>: <detail>. โ€ฆ` |
2907
+ | 44 | | `<where>: "<key>" is not one of its options, or not of the option's type.` (`<where>` is `bootstrapState`, `$behavior`, or `state` for a `$behavior` that is not an object) |
2908
+ | 45 | | `a re-set state may not change $behavior: create the element again.` |
2909
+ | 46 | | `$features must be an array of add-on names (["temporal", "formats"]).` |
2910
+ | 47 | | `a second <wcs-state> on the same root: there is one state tree per root โ€” โ€ฆ` |
2911
+ | 48 | | `the locale "<locale>" (<html lang> or bootstrapState's locale) is not a language tag Intl takes (en-US, not en_US): the locale filters use "en".` |
2912
+ | 49 | | `<wcs-state src="โ€ฆ"> failed to initialize.` |
2913
+ | 50 | | `<wcs-state src="โ€ฆ"> $connectedCallback failed.` |
2914
+ | 101 | `binding-syntax` | `Invalid bindText: "<text>". Missing ':' separator between propPart and statePart.` |
2915
+ | 102 | `binding-syntax` | `"<text>": "<keyword>" takes no modifiers or filters on its left side โ€” write "<keyword>:".` |
2916
+ | 103 | `binding-syntax` | `"<text>": "else" takes no value โ€” write "else:".` |
2917
+ | 104 | `binding-syntax` | `Invalid spread binding "<text>": spread target path is required.` |
2918
+ | 105 | `binding-syntax` | `Invalid spread binding "<text>": filters are not allowed on spread targets.` |
2919
+ | 106 | `binding-syntax` | `"<prop>": a leading "." binds an element property by name โ€” โ€ฆ` |
2920
+ | 107 | `binding-syntax` | `unterminated <quote> quote in the filter arguments "(<args>)". Close the quote.` |
2921
+ | 108 | `binding-syntax` | `Invalid filter format: missing closing parenthesis in "<filter>".` |
2922
+ | 109 | `binding-syntax` | `Invalid filter format: missing opening parenthesis in "<filter>".` |
2923
+ | 110 | `binding-syntax` | `Invalid filter format: ")" comes before "(" in "<filter>".` |
2924
+ | 111 | `binding-syntax` | `"<filter>": unexpected "<text>" after the filter's closing ")" โ€” separate filters with "\|" โ€ฆ` |
2925
+ | 112 | `binding-syntax` | `an empty filter in "<source>" โ€” remove the extra "\|" or name the filter.` |
2926
+ | 113 | `binding-syntax` | `"<name>" is not a filter name: a modifier list "#<modifiers>" comes before the input filters, not inside one` |
2927
+ | 114 | `binding-syntax` | `"<name>" is not a filter name: "#" cannot appear in one.` |
2928
+ | 115 | `binding-syntax` | `"<prop>": a binding takes one modifier list after a single "#"` |
2929
+ | 116 | `binding-syntax` | `"<prop>": the left side of a binding must name a property โ€” โ€ฆ` |
2930
+ | 117 | `binding-syntax` | `"<path>" has <n> path segments โ€” the limit is 512.` |
2931
+ | 119 | `binding-syntax` | `"<text>": the right side of a binding must name a state path โ€” โ€ฆ` |
2932
+ | 120 | `binding-syntax` | `"<path>": a state path cannot go through "__proto__" or "prototype" (it would reach every object's prototype).` |
2933
+ | 121 | `binding-syntax` | `"<text>": "for:" takes no filters โ€” โ€ฆ Declare a getter that returns the filtered list and loop over it ("for: <getter>").` |
2934
+ | 201 | `template-syntax` | `Invalid bindText: "<text>". 'if', 'elseif', 'else', and 'for' bindings must be single binding.` |
2935
+ | 202 | `template-syntax` | `"<type>:" must follow an "if:" template` |
2936
+ | 203 | `template-syntax` | `"<name>:" replaces its element, so it cannot be used inside a "for" / "if" template โ€ฆ: bind innerHTML: on a wrapper element instead.` |
2937
+ | 204 | `template-syntax` | `a "<type>:" template at the top of inserted content was not rendered, โ€ฆ wrap it in an element.` |
2938
+ | 301 | `binding-path-missing` | `Path "<path>" does not exist on the state tree.` |
2939
+ | 401 | `binding-type-expectation` | `class.<name> needs a boolean, got <type>.` |
2940
+ | 501 | `filter-unknown` | `filter not found: <name>.` |
2941
+ | 601 | `filter-arity` | `filter "<name>" requires at least <min> argument(s) (<given> given).` |
2942
+ | 602 | `filter-arity` | `filter "<name>" accepts at most <max> argument(s) (<given> given).` |
2943
+ | 701 | `getter-cycle` | `"<path>" depends on itself` |
2944
+ | 801 | `getter-depth-exceeded` | `"<path>"` |
2945
+ | 901 | `index-arity` | `<api>("<path>") takes <depth> index(es), got <n>.` |
2946
+ | 902 | `index-arity` | `<api>("<path>") takes at most <depth> index(es), got <n>.` |
2947
+ | 1001 | `index-param-range` | `"<key>": list index parameters run from $1 to $128.` |
2948
+ | 1101 | `recursion-unsupported` | `"<path>" uses "**", which is not accepted here.` |
2949
+ | 1201 | `token-misconfigured` | `"<prop>: <path>": the right-hand side must be $command.<name>` |
2950
+ | 1202 | `token-misconfigured` | `<tag> declares no static wcBindable (<binding>).` |
2951
+ | 1203 | `token-misconfigured` | `<tag> declares no command "<method>".` |
2952
+ | 1204 | `token-misconfigured` | `<tag> declares no property "<prop>".` |
2953
+ | 1301 | `token-undeclared` | `eventToken "<name>" is not declared in $eventTokens.` |
2954
+ | 1302 | `token-undeclared` | `"$command.<name>" is not declared in $commandTokens.` |
2955
+ | 1401 | `wildcard-rank` | `"<path>" needs <depth> enclosing loop level(s); the scope provides <n>.` |
2956
+ | 1402 | `wildcard-rank` | `"<path>" is relative: it needs an enclosing "for" template` |
2957
+ | 1403 | `wildcard-rank` | `"<path>" ranges over the rows of "<list>", but the enclosing "for" template at that level renders "<loop>".` |
2958
+ | 1501 | `spread-no-bindable` | `<tag> declares no static wcBindable (<binding>).` |
2959
+ | 1601 | `declaration-alias` | `<old> was removed: write <name>.` (`$streams`, `$updatedCallback`) |
2960
+ | 1701 | `name-alias` | `<old> was removed: write <name>.` (`$trackDependency`, `$untrackDependency`) |
2961
+
2962
+ The source of the numbers and the sentences is `src/diagnostics/messages.ts`.
2963
+
2964
+ </details>
2965
+
2761
2966
  ### Wiring to a path that does not exist is reported
2762
2967
 
2763
- When a wired path provably does not resolve against the state, you get one warning at binding time (at declaration time for `$watch` and `$scan`). The diagnostic codes are shared by the console, `@wcstack/lint`, and the VS Code extension:
2968
+ When a wired path provably does not resolve against the state, the `diagnostics` add-on prints one warning, one macrotask after the binding (or the `$watch` key) is set up. The diagnostic codes are shared by the console, `@wcstack/lint`, and the VS Code extension:
2764
2969
 
2765
2970
  ```
2766
2971
  [@wcstack/state] [wcs/binding-path-missing] Bound path "user.nmae" does not resolve on the state tree:
@@ -2771,47 +2976,46 @@ dropped. Validate statically: npx @wcstack/lint <file>.
2771
2976
  | Situation | Behavior |
2772
2977
  |---|---|
2773
2978
  | Typo in a nested path (`user.nmae`) | `console.warn` (`wcs/binding-path-missing`). Updates still never arrive โ€” you fix it |
2774
- | Typo in a top-level path (`cout`) | Throws on read, with the same wording and did-you-mean |
2979
+ | Typo in a top-level path (`cout`) | Throws on read โ€” `[wcs/binding-path-missing] Path "cout" does not exist on the state tree. Did you mean "count"?` (`#301`) โ€” so the binding fails to apply, reported like any binding failure |
2775
2980
  | Typo in a `$watch` key | `console.warn` (`wcs/watch-path-missing`), reported even for a single segment |
2776
- | Typo in a `$scan` `from` / `resetOn` path | `console.warn` (`wcs/scan-path-missing`), reported even for a single segment. The scan never folds (or never resets) |
2777
2981
 
2778
2982
  The check **under-approximates**: it stays silent for anything it cannot decide statically, because a false alarm costs more than a missed one. None of these warn:
2779
2983
 
2780
2984
  - A `null` / `undefined` parent (the "seed as `null`, assign later" shape)
2781
2985
  - Row fields of a list that starts empty (the row shape is unknown)
2782
2986
  - Sub-properties of an intermediate getter's return value
2783
- - Marker paths of mounted components (`#mโ€ฆ` โ€” private keys and getters live on the mount overlay, not the raw state)
2987
+ - Paths under the mount path of a volume that is still loading โ€” they are checked once it settles
2784
2988
  - Reserved `$` namespaces (`$command.*` and friends)
2785
2989
 
2786
- So **no warning is not a proof of correctness.** For exhaustive checking, run `npx @wcstack/lint <file>`.
2990
+ So **no warning is not a proof of correctness.** For exhaustive checking, run `npx @wcstack/lint <file>`. A re-set (`setInitialState()`) checks every path again against the new state.
2787
2991
 
2788
2992
  ### Index arity, wildcard rank, and getter cycles are checked too
2789
2993
 
2790
- 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`.
2994
+ Anything that follows mechanically from the path string is reported at runtime and by the linter under the same diagnostic code. Five of the codes below are **runtime-only** โ€” the linter does not emit them: `wcs/getter-depth-exceeded`, `wcs/recursion-context`, `wcs/recursion-shared-list`, `wcs/recursion-cycle` and `wcs/recursion-depth-exceeded`.
2791
2995
 
2792
2996
  | Diagnostic | What it checks | Fix |
2793
2997
  |---|---|---|
2794
2998
  | `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 |
2795
- | `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)` |
2796
- | `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 |
2797
- | `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 |
2999
+ | `wcs/wildcard-rank` | In markup, the path's `*` count (and the N in `$N`) must not exceed the enclosing `for` nesting (`#1401`), a `.` path needs an enclosing `for` (`#1402`), and each `*` is the row of the `for` around it at that level โ€” not a row of another list (`{{ b.*.y }}` inside `for: a`, `#1403`) | Add a `for`, or read the other list's row in a getter with `$resolve(path, indexes)` |
3000
+ | `wcs/getter-cycle` | Path getters must not form a dependency cycle: the evaluation reached a getter occurrence it is already evaluating | Break the cycle |
3001
+ | `wcs/getter-depth-exceeded` | Getter evaluation nests deeper than the engine evaluates in one pass (128 frames), with no occurrence visited twice โ€” the data is simply that deep | Aggregate in fewer levels, or flatten the tree |
2798
3002
  | `wcs/index-param-range` | `$N` must name an existing wildcard level: `$1` through `$128`, no leading zeros | Use a level that exists |
2799
3003
  | `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 |
2800
- | `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 |
2801
- | `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 |
3004
+ | `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 โ€” and, with the `diagnostics` add-on, 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 |
3005
+ | `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 โ€” none at all (`get "nodes.**"`), an empty segment (`nodes.**.`, `nodes.**..x`) or a bare `*` right after `**` (`nodes.**.*`) | Spell the anchor as declared, and a real path after it |
2802
3006
  | `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 |
2803
- | `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 |
2804
- | `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 |
3007
+ | `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; at runtime a mapper and `{ spread: true }` throw `[wcs/recursion-setall-form]`, and omitted indexes throw `#9`, as any `$setAll` without `indexes` does | Omit for the current depth, `[]` for every depth |
3008
+ | `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 (a leaf under a child node, `nodes.**.children.*.value`, is one) |
2805
3009
  | `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 |
2806
3010
  | `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 |
2807
3011
  | `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 |
2808
3012
 
2809
3013
  The form each `wcs/recursion-*` row is refusing โ€” and the form to write instead โ€” is spelled out under [Recursive Paths](#recursive-paths-recursion).
2810
3014
 
2811
- Previously **extra indexes were silently discarded** by both APIs, so a mixed-up call returned a plausible-looking wrong value. Both now throw:
3015
+ Extra indexes are never discarded: a mixed-up call that would return a plausible-looking wrong value throws instead.
2812
3016
 
2813
3017
  ```javascript
2814
- // โŒ Only one "*" in the path, two indexes given โ€” used to return items[0]'s value
3018
+ // โŒ Only one "*" in the path, two indexes given โ€” throws [wcs/index-arity]
2815
3019
  this.$resolve("items.*.price", [row, col]);
2816
3020
 
2817
3021
  // โœ… Two levels, two indexes
@@ -2820,29 +3024,63 @@ this.$resolve("matrix.*.*", [row, col]);
2820
3024
  this.$getAll("matrix.*.*", [row]);
2821
3025
  ```
2822
3026
 
3027
+ ### Initialization failures
3028
+
3029
+ A `<wcs-state>` that fails to initialize is reported once with `console.error` โ€” first the element and where its state comes from, then the error:
3030
+
3031
+ ```
3032
+ [@wcstack/state] <wcs-state src="./state.js"> failed to initialize. SyntaxError: โ€ฆ
3033
+ ```
3034
+
3035
+ The header (`#49`) names `mount=`, `bind-component=`, `state=` and `src=` when the element has them. Its `connectedCallbackPromise` rejects with the error, unwrapped, and its `initializePromise` resolves, so one element's failure never blocks the rest of the page.
3036
+
3037
+ What fails initialization:
3038
+
3039
+ - A source that cannot be read ([State Initialization](#state-initialization)).
3040
+ - An invalid declaration: a removed name (`$scan`, `$streams`, `$updatedCallback`), a `$behavior` / `$features` / `$commandTokens` / `$eventTokens` / `$on` of the wrong shape, a declaration whose add-on is not installed (`[wcs/feature-not-installed]`).
3041
+ - A markup error found while the page is bound: a syntax error, an unknown filter or a wrong argument count, a wildcard-rank error, `for:` with filters, `outerHTML:` in a template, a wc-bindable misconfiguration on an element whose class is already defined. The walk stops at the error: what comes before it in document order is bound, what comes after it is not. Run `npx @wcstack/lint` before you deploy.
3042
+ - A second root `<wcs-state>` on a root that another one already binds (`#47`). There is one state tree per root; graft a subtree with `mount=`.
3043
+
3044
+ Inside a `for:` / `if:` row, an error found while a binding is attached (an undeclared token, a member the element does not declare) is that binding's failure instead: it goes to `$errorCallback`, and the row is still built. A binding to a top-level key the state does not have is a failed apply, not an initialization failure. An element that failed to initialize cannot be re-armed โ€” `setInitialState()` throws (`#14`); remove it and create a new one.
3045
+
3046
+ **A failing `$connectedCallback` is not an initialization failure.** `$connectedCallback` runs once the bindings are built; one that throws or rejects is reported as `<wcs-state โ€ฆ> $connectedCallback failed.` (`#50`) followed by the error, and `connectedCallbackPromise` rejects with it โ€” but the page is bound: `getBindingsReady()` resolves, and the element can still be re-set. On a reconnect, a failing `$connectedCallback` is reported with `console.error`.
3047
+
3048
+ **What the promises promise:**
3049
+
3050
+ | | `initializePromise` | `connectedCallbackPromise` |
3051
+ |---|---|---|
3052
+ | Root `<wcs-state>` | Resolves once the state is loaded and the bindings are built โ€” and also when initialization fails | Resolves after `$connectedCallback`; rejects on an initialization failure and on a failing `$connectedCallback` |
3053
+ | DCC definition (`<wcs-state>` in a `data-wc-definition` shadow root) | Resolves | Resolves once the tag is defined; rejects when the definition fails (the state fails to load, a `$bindables` / `$commands` error, a `bind-component` there) โ€” the tag is then not defined |
3054
+ | Mounted component (`<wcs-state bind-component>`) | Resolves once the component's bindings are built โ€” also on failure | Rejects when the mount fails: the host's property is not an object, a wiring error, a second connected `<wcs-state bind-component>` in one component, a component wired to a root that failed to initialize (`<tag>.state will not mount: the root state failed to initialize.`), or a failing `$connectedCallback` after the component is rendered |
3055
+ | Volume (`<wcs-state mount>`) | Resolves | Always resolves โ€” a volume that fails or is refused reports and settles without grafting (a missing `scopes` add-on rejects it) |
3056
+
3057
+ `getBindingsReady(root)` resolves once the bindings of `root` (a `document` or a shadow root) are built by a `<wcs-state>` connected to it โ€” without waiting for `$connectedCallback`, however slow it is, whether it settles or throws. It rejects only when every `<wcs-state>` connected to that root failed before building its bindings, with the first failure. A stray element (a second root refused with `#47`, one whose source fails to load) changes nothing while another binds the root; when the root `<wcs-state>` is replaced (removed, and a new one added), the new one decides. It stays pending while the only `<wcs-state>` there waits for `setInitialState()`, and resolves at once when there is none โ€” never one, or only ones taken out of that root since. On the shadow root of a DCC definition or of a Shadow DOM component, it follows that `<wcs-state>`: it resolves when the component's bindings are built and rejects when the mount fails. A Light DOM component binds content in the page's root, and its failure does not reach `getBindingsReady(document)`. A volume never makes it reject.
3058
+
3059
+ `@wcstack/server`'s `renderToString()` and `@wcstack/testing`'s `mount()` wait for every `connectedCallbackPromise`, a Light DOM component's included, so they reject in the cases above too. The cases are pinned by [`__tests__/init-failure.test.ts`](__tests__/init-failure.test.ts).
3060
+
2823
3061
  ### One failing binding is confined to that binding
2824
3062
 
2825
- 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`):
3063
+ 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 `$errorCallback`, or else to `console.error`, followed by the error, and to DevTools (`state:binding-apply-error`):
2826
3064
 
2827
3065
  ```
2828
- [@wcstack/state] binding "text: items.*.label" failed to apply; the rest of this batch continues.
3066
+ [@wcstack/state] binding "text: items.*.label" failed to apply.
2829
3067
  ```
2830
3068
 
2831
- Without that confinement, a single throw left "new values, half-updated DOM" behind, and silently dropped every `$watch` handler and stream restart for the batch โ€” quietly breaking the firing-order contract documented above.
3069
+ A list that fails to sync or render (a missing key, a getter that throws) is reported the same way, as its `for` binding's failure, and keeps the rows it had.
2832
3070
 
2833
3071
  ### A write made while rendering cannot loop forever
2834
3072
 
2835
- Some writes happen while bindings are being applied: a wc-bindable element in a new `for:` row hands its initial value to the state (output-only members, `#init=element`), and `$renderedCallback` may write. Each such write starts a new batch, and that batch renders again. If the rendering writes something new every time โ€” say a row element whose output is bound to the very key the list's getter reads, returning a different value per instance โ€” the page would spin in microtasks and freeze. The chain is cut off after 100 links: the batch that would be the 101st is not rendered (its values stay in the state), and one report names the paths it carried:
3073
+ Some writes are fed back by rendering itself: an element's write-back (a two-way input, a wc-bindable event โ€” including one an element dispatches synchronously while a binding sets one of its properties โ€”, an output-only member's initial value), and a write made in `$renderedCallback` (also after an `await` in it, until it settles), in a `$watch` handler, or by a `$stream` restart. Each such write starts a new batch, and that batch renders again. If the rendering writes something new every time โ€” say a row element whose output is bound to the very key the list's getter reads, returning a different value per instance โ€” the page would spin in microtasks and freeze. Batches in a row that only rendering fed are counted: past 100, the next one is not rendered (its values stay in the state), and one report names the paths it carried:
2836
3074
 
2837
3075
  ```
2838
- [@wcstack/state] render chain depth limit exceeded; bindings for this batch were not applied. { maxDepth: 100, paths: ["mode", "view", "view.*", "view.*.m"] }
3076
+ [@wcstack/state] render chain depth limit exceeded (100 drains that rendering itself started); bindings for this batch were not applied. ["mode", "view", "view.*", "view.*.m"]
2839
3077
  ```
2840
3078
 
2841
3079
  The limit is higher than the other two limits (32) because each link here is a whole render. A chain that renders, measures and adjusts can legitimately run past 32 links and still settle โ€” shrinking a heading 1px at a time from 64px to 18px renders 47 times, and drawing 400 rows 10 at a time is 40 links.
2842
3080
 
2843
- Only writes made synchronously while bindings are being applied extend the chain โ€” including an event that an element dispatches synchronously while a binding sets one of its properties. A write that arrives from outside the drain starts the count again from zero: a user action, an I/O node event that arrives asynchronously, a `$stream` value, an `await` continuation. Writes from `$scan`, `$watch` and `$stream` restarts neither extend nor reset it โ€” they carry the chain over to the batch they start. So a cycle that goes through `$watch` or `$scan` (an element writes `x`, `$watch: { x(v) { this.mode = v } }`, and the list reads `mode`) is cut off too, one link per render; the `$watch` limit does not catch it, because the handler's write comes back through a render rather than straight into another handler. A write-back that settles, such as the initial sync of an ordinary list's rows, adds one link and stops. Because the chain is carried over, finite chains joined by `$watch` or `$scan` share the 100 links: a heading fit that takes 47 links, followed by a second one that a `$watch` starts when the first finishes, settles (94 renders), but a third one joined the same way is cut off partway. The chain is reported once: when the batch that was not rendered still hands the chain on through `$watch` or `$scan`, the batches that follow are not rendered either, without another report. The next write from outside renders normally. DevTools receives `state:render-chain-limit`.
3081
+ A write from code โ€” an event handler, `$connectedCallback`, a `$stream` value, an `await` continuation in your own code โ€” ends the chain, and so does a pause of one task: the count starts again from zero. An element that reports its value from a microtask (Lit's `updated()`, for example) stays in the chain, so a loop through such an element is cut too. A cycle through `$watch` (an element writes `x`, `$watch: { x(v) { this.mode = v } }`, and the list reads `mode`) is cut, one link per render; the `$watch` limit does not catch it, because the handler's write comes back through a render rather than straight into another handler. A `$stream` restart's writes count as well: element write-backs that restart a stream a microtask apart are cut after about 50 renders (100 without the stream). A write-back that settles, such as the initial sync of an ordinary list's rows, adds one link and stops, and finite chains joined by `$watch` share the 100 links. The chain is reported once; the next write from code renders normally. DevTools receives `state:render-chain-limit`.
2844
3082
 
2845
- A loop whose closing write arrives from a microtask is outside this limit, as it was before the limit existed: an element that reports its value from a microtask (Lit's `updated()`, for example) and an async `$renderedCallback` that writes after an `await`. Such a write cannot be told apart from a finite async loop โ€” an `async` function that writes 10,000 times with an `await` of a resolved promise between the writes also renders 10,000 times without a single macrotask โ€” so a limit on renders in a row would stop legitimate code as well.
3083
+ A single update that keeps producing work within itself โ€” writes made while its bindings are applied โ€” is stopped after 32 passes and reported once per chain: `updates did not settle after 32 passes`, with the paths (DevTools: `state:render-chain-limit` with `maxDepth: 32`).
2846
3084
 
2847
3085
  ### Values and the DOM are never rolled back
2848
3086
 
@@ -2850,46 +3088,55 @@ Every failure mode reports and continues; nothing already applied is reverted:
2850
3088
 
2851
3089
  | Mechanism | Limit | On exceeding |
2852
3090
  |---|---|---|
2853
- | Propagation hops | 32 | Quarantine the transaction's remaining records |
2854
- | `$watch` write chain | 32 | Skip `$scan` / `$watch` firing for that batch, and the `$stream` restarts its over-limit writes would trigger |
3091
+ | Passes within one update | 32 | Stop that update; what it would still have rendered waits for the next change |
3092
+ | `$watch` / `$stream` restart chain | 32 | Skip the handlers and stream restarts that the over-limit writes would fire; the batch's other handlers still run |
2855
3093
  | Render write chain | 100 | Skip applying bindings for that batch |
3094
+ | Getter nesting | 128 | Throw `wcs/getter-depth-exceeded` |
2856
3095
  | Binding apply failure | โ€” | Skip that one binding |
2857
3096
 
2858
- ### Preparing for 4.0 (`wcs/v4-migration`)
3097
+ ## Upgrading from 3.x
2859
3098
 
2860
- 3.5 is the last 3.x minor. It names each name and option that 4.0 removes or moves, and still runs it the 3.x way. Each warning is a `console.warn` under the code `wcs/v4-migration`, printed once per name per page. It says what 4.0 does and what to write instead:
3099
+ 4.0 replaces the engine inside `@wcstack/state`. What you write against stays โ€” `<wcs-state>`, `data-wcs`, `{{ }}` and comment bindings, `for` / `if`, paths and path getters, the `$` APIs, `bind-component`, `mount=`, `enable-ssr` and the package entries. Every difference, with the reason for it and what to write instead, is in the [3.x โ†’ 4.0 migration guide](https://github.com/wcstack/wcstack/blob/main/docs/migration-v4.md). The path is: move to 3.5 first and clear its `[wcs/v4-migration]` warnings and what the 3.5 lint reports, then move every `@wcstack/*` package to 4.0 at once โ€” `@wcstack/server` together with the client.
2861
3100
 
2862
- ```
2863
- [@wcstack/state] [wcs/v4-migration] filter "uc" is removed in 4.0: write "upper" (its name since 3.2).
2864
- See "Preparing for 4.0" in the @wcstack/state README.
2865
- ```
3101
+ In short:
2866
3102
 
2867
- | Form | 3.x | 4.0 | Write instead |
2868
- |---|---|---|---|
2869
- | The filter names 3.2 renamed: `inc` `dec` `fix` `uc` `lc` `cap` `rep` `rev` `pad` `null` | Aliases | `[wcs/filter-unknown]` | `add` `sub` `toFixed` `upper` `lower` `capitalize` `repeat` `reverse` `padStart` `nullIfEmpty` |
2870
- | `$trackDependency` / `$untrackDependency` | Aliases | Throws `[wcs/name-alias]` | `$dependOn` / `$untracked` |
2871
- | `$updatedCallback` / `$streams` | Aliases | Throws `[wcs/declaration-alias]` | `$renderedCallback` / `$stream` |
2872
- | `substr(start, length)` | Works | `[wcs/filter-unknown]` | `slice(start, start + length)` (the end index, not a length) |
2873
- | `$scan` | Works | Throws | `$watch` for state paths, `$on` for event tokens |
2874
- | `bootstrapState({ debug })`, `commentTextPrefix`, `enablePropagationContext` | Work | Throw | Remove them. 4.0 reads comment bindings as `<!--@@: path-->` and `<!--@@wcs-text: path-->` only |
2875
- | `bootstrapState({ enableMustache })`, `sameValueGuard`, `enableDirectionalInitialSync` | Page-wide options | Throw; each state tree declares them in `$behavior` | Keep them in 3.x. When you upgrade, move them to `$behavior` in each root and component state (a component does not inherit its host's; a volume may not declare one) |
2876
- | An unknown `bootstrapState` key, or a value of another type (`null`, an array, a non-string tag name, a `tagNames` key other than `state` / `ssr`) | Ignored | Throws | Remove or fix it |
2877
- | A state key `$behavior` | Plain data; 3.x does not read it | The state's behavior options | Warned only when its value differs from the option 3.x runs with, or has a shape 4.0 throws on |
2878
- | A state key `$features` | Plain data | The add-ons the state needs | Warned only when 4.0 would throw on it (not an array of add-on names) |
2879
- | A volume (`<wcs-state mount>`) that declares `$watch`, `$listKeys` or `$renderedCallback` (`$updatedCallback`) | Run relative to the mount path | The volume is not grafted (`console.error`) | Move them to the root state, with absolute paths |
2880
- | A volume that injects root paths (`data-wcs="state.<key>: โ€ฆ"`) | Works (3.1) | The volume is not grafted (`console.error`) | Read the root path in a root getter |
2881
- | A volume that declares `$behavior` / `$features` | Plain data, grafted | The volume is not grafted (`console.error`) | Declare them on the root state |
3103
+ - **Removed:** `$scan` (fold in a `$watch` or an `$on` handler); the 3.2 old names โ€” the filters `inc` `dec` `fix` `uc` `lc` `cap` `rep` `rev` `pad` `null`, `$trackDependency` / `$untrackDependency`, `$updatedCallback` / `$streams`; the `substr` filter (`slice(start, start + length)`); the `bootstrapState()` options `debug`, `commentTextPrefix` and `enablePropagationContext`; `IStateElement.listPaths` / `getterPaths` / `setterPaths` / `nextVersion()`.
3104
+ - **Configuration:** `enableMustache`, `sameValueGuard` and `enableDirectionalInitialSync` move from `bootstrapState()` to the `$behavior` of each root state, and every package's `bootstrapXxx()` throws on an option it does not have ([Configuration](#configuration)).
3105
+ - **Events:** `on*:` handlers of the common bubbling events are delegated to the root, so `event.currentTarget` is the root; `#direct` opts a binding out ([Event Handling](#event-handling)).
3106
+ - **Lists:** writing a list element replaces the value at that position โ€” to move rows, assign a new array ([`$resolve`](#resolve--access-by-explicit-index)); `for:` takes no filters ([Loop](#loop-for)); `data-wcs` is removed from row and branch elements, and `outerHTML:` / `outerText:` cannot be used inside templates ([What the page walk binds](#what-the-page-walk-binds)).
3107
+ - **Volumes:** a volume no longer runs `$watch`, `$listKeys` or `$renderedCallback`, and takes no injections ([`mount=`](#mounting-additional-state-mount)).
3108
+ - **SSR:** the output of a 3.x server is rendered again on the client ([Server-Side Rendering](#server-side-rendering)).
3109
+ - **`$watch`:** a row watch fires without a `for:` or `$listKeys`, a watch on a row getter is evaluated for every row, and assigning a whole array fires only the rows that came in ([Watch](#watch-watch)).
3110
+ - **TypeScript:** import `defineState` from `@wcstack/state/define` โ€” imported from `@wcstack/state`, it keeps the engine in a bundle ([TypeScript Support](#typescript-support)).
2882
3111
 
2883
- Where each fires: an old declaration key, `$scan`, `$behavior` and `$features` when the state object is loaded (a volume's when it is grafted); an old filter name and `substr` when a binding that uses it is set up; a `bootstrapState` option when `bootstrapState()` is called; `$trackDependency` / `$untrackDependency` when they are read. The checks sit where an old form is resolved, so a page that writes the canonical names behaves exactly as before; what it runs differs by one comparison where a filter is set up and one where `$dependOn` / `$untracked` is read, with no measurable difference.
3112
+ `@wcstack/lint`, `@wcstack/typescript` and the VS Code extension (from 2.0.0) carry the 4.0 rules in the releases published with 4.0. Keep the 3.5-era versions for 3.x projects: the 4.0 rules report forms 3.x still accepts.
2884
3113
 
2885
- The warnings ship in the full entries (`@wcstack/state` and `/auto`). A page on the split entries (`@wcstack/state/core` with `features/*`) prints none. There is no other switch, as in 2.6. A warning fires only on the path that reaches the old form, so a clean console proves nothing about the paths the page did not run. `npx @wcstack/lint <file>` and the VS Code extension report the old names statically (`wcs/name-alias`, info, since 3.2).
3114
+ ## Security
2886
3115
 
2887
- The three options that move to `$behavior` keep warning while a page passes them: 3.x reads them only from `bootstrapState`, so there is nothing to change before the upgrade. Writing `$behavior` early is harmless and is checked against the options 3.x runs with. One 4.0 rule has no 3.5 warning: a re-set (`setInitialState()` on an initialized element) may not change `$behavior` โ€” 4.0 throws (#45), so create the element again instead.
3116
+ `@wcstack/state` puts values into the DOM; it does not sanitize them, and it never reads a value as markup. What it does read as markup is the page itself โ€” so the rules are about what reaches the page, and what you bind where.
2888
3117
 
2889
- `on*#direct:` works in 3.x already. 4.0 delegates `on*:` to the root and adds `#direct` to attach the listener to the element. 3.x attaches every `on*:` to its element and accepts the `#direct` modifier silently. Write it now where the element-attached behavior matters: `onclick#direct,stop:` stops your own listeners on the ancestors, and the handler still runs when an ancestor calls `stopPropagation()`.
3118
+ **Markup you did not write must not reach the page walk.** When a `<wcs-state>` binds its root, it reads every `{{ โ€ฆ }}`, every `data-wcs` attribute and every comment binding (`<!--@@: โ€ฆ-->`) in the markup ([What the page walk binds](#what-the-page-walk-binds)). User content that a server template or your page code writes into the page's markup, rather than into state, is read the same way: a user name typed as `{{ apiKey }}` renders the state's `apiKey`, and a `data-wcs` attribute in a posted message binds โ€” client-side template injection (CSTI). Comment bindings are bound even with `$behavior.enableMustache: false`, so turning `{{ }}` off does not protect against it. There is no opt-out attribute in 4.0 (no `data-wcs-ignore`). Instead:
3119
+
3120
+ - Put user content in **state**, and bind it with `textContent:` (or `{{ }}` / a comment binding, which are text) โ€” a value is never read as markup.
3121
+ - Bind user HTML with `innerHTML:` / `html:` only after sanitizing it. The HTML sinks (`innerHTML:`, `outerHTML:`, `srcdoc:`, `html:`) pass every value that is not already a `TrustedHTML` through the policy you install (`setTrustedTypesPolicy()`, or `globalThis[Symbol.for("wcstack.trustedTypes.policy")]`), with or without Trusted Types in the browser โ€” so an installed sanitizer always runs. This package never signs a value with an identity policy ([docs/csp.md ยง7](../../docs/csp.md)).
3122
+ - Do not let a server template or your own code write user content into the page's markup: deliver it as data (in the state, or fetched into it). HTML escaping is not enough โ€” it leaves `{{ }}` as it is.
3123
+
3124
+ **Values are not checked.** A `javascript:` URL bound to `href:`, `src:` or `attr.href:`, and a handler string bound to `attr.onclick:` (any `attr.on*:`), are written as they are. Validate URLs yourself, and run the page under a Content-Security-Policy without `unsafe-inline` in `script-src`, which keeps both from running ([docs/csp.md](../../docs/csp.md)).
3125
+
3126
+ **Keep the comments in SSR output.** Text bindings and the row and branch markers of `@wcstack/server`'s output are comments. Removing them in a later step (html-minifier's `removeComments` and the like) breaks hydration, and a `{{ }}` inside a server-rendered value may then be read as a binding on the client.
2890
3127
 
2891
3128
  ## Configuration
2892
3129
 
3130
+ Configuration is split by what it decides:
3131
+
3132
+ | Where | What it decides | When it is read |
3133
+ |---|---|---|
3134
+ | `bootstrapState({ โ€ฆ })` | How this page spells the markup โ€” the tag names, the binding attribute, the anchor comments โ€” and the default locale | Once, page-wide, when the elements are defined |
3135
+ | `$behavior` in a state | How that state tree behaves โ€” `{{ }}`, the same-value guard, direction-aware initial sync | When that state is loaded |
3136
+ | `$features` in a state (and `features=` on the root, with the split auto entry) | The add-ons that state needs | When that state is loaded |
3137
+
3138
+ ### `bootstrapState()` options
3139
+
2893
3140
  Pass a partial configuration object to `bootstrapState()`:
2894
3141
 
2895
3142
  ```javascript
@@ -2909,20 +3156,49 @@ All options with defaults:
2909
3156
  | `bindAttributeName` | `'data-wcs'` | Binding attribute name |
2910
3157
  | `tagNames.state` | `'wcs-state'` | State element tag name |
2911
3158
  | `tagNames.ssr` | `'wcs-ssr'` | Tag name of the SSR hydration-data element |
2912
- | `locale` | `<html lang>`, else `'en'` | Locale for the locale-dependent filters (`locale` / `date` / `time` / `datetime`) โ€” see [Locale](#locale) |
2913
- | `debug` | `false` | Debug mode. Removed in 4.0 (3.5 warns) |
2914
- | `enableMustache` | `true` | Enable `{{ }}` syntax. Moves to the state's `$behavior` in 4.0 (3.5 warns) |
2915
- | `enableDirectionalInitialSync` | `true` | Direction-aware binding authority (`#init=` / `#sync=` binding modifiers) โ€” see [Binding Authority](#binding-authority-init--sync). Default on; set `false` to opt out. Moves to the state's `$behavior` in 4.0 (3.5 warns) |
2916
- | `enablePropagationContext` | `true` | Causal propagation tracking across bindings (echo/diamond loop prevention). Default on; set `false` to opt out. Removed in 4.0 (3.5 warns) |
3159
+ | `commentForPrefix` | `'wcs-for'` | Text of the comment a `for:` template leaves in its place as its anchor |
3160
+ | `commentIfPrefix` / `commentElseIfPrefix` / `commentElsePrefix` | `'wcs-if'` / `'wcs-elseif'` / `'wcs-else'` | Texts of the anchor comments of an `if:` / `elseif:` / `else:` chain |
3161
+ | `locale` | `<html lang>` (read when the module is evaluated), else `'en'` | Locale for the locale-dependent filters (`locale` / `date` / `time` / `datetime`) โ€” see [Locale](#locale) |
2917
3162
  | `enableContractAnalyzer` | `false` | Opt-in dev-time contract analyzer (exposes `analyzeContract`) |
2918
- | `sameValueGuard` | `true` | Drop a primitive write whose value is `Object.is`-equal to the current one before anything is enqueued โ€” bindings and `$watch` effectively fire on change only; reference types always pass. `false` lets equal writes through and makes `$watch`'s `prev` `undefined`. Moves to the state's `$behavior` in 4.0 (3.5 warns) |
2919
3163
 
2920
- 4.0 removes `debug`, `commentTextPrefix` and `enablePropagationContext`, and moves `enableMustache`, `enableDirectionalInitialSync` and `sameValueGuard` to the state's `$behavior`. 3.5 warns when a page passes them โ€” see [Preparing for 4.0](#preparing-for-40-wcsv4-migration).
3164
+ `bootstrapState()` throws on an option it does not have โ€” one 4.0 moved to `$behavior` or removed included โ€” on a value whose type differs from the default (`null`, or an array where an object is expected), on a name under `tagNames` other than `state` / `ssr`, and on a tag name that is not a string (`#44`). It checks every option before applying any, so nothing is applied when it throws. An `undefined` value is skipped (`bootstrapState({ locale: maybeLocale })`).
3165
+
3166
+ ```
3167
+ [@wcstack/state] bootstrapState: "enableMustache" is not one of its options, or not of the option's type. 4.0 moved it to the state's $behavior.
3168
+ ```
3169
+
3170
+ Every package's `bootstrapXxx()` โ€” `bootstrapRouter`, `bootstrapFetch`, `bootstrapAutoloader`, โ€ฆ โ€” applies the same rule. Pages that only load the `/auto` entries call them without options: they keep the defaults, and set the locale through `<html lang>`. `getConfig()` returns the current configuration (read-only).
3171
+
3172
+ ### `$behavior`
3173
+
3174
+ How a state tree behaves is declared by its state, not by the page:
3175
+
3176
+ ```javascript
3177
+ export default {
3178
+ $behavior: { enableMustache: false, sameValueGuard: false },
3179
+ count: 0,
3180
+ };
3181
+ ```
3182
+
3183
+ | Key | Default | Description |
3184
+ |---|---|---|
3185
+ | `enableMustache` | `true` | `{{ }}` in text is a binding ([Mustache Syntax](#mustache-syntax)). `false` leaves the braces as text; comment bindings are bound either way |
3186
+ | `sameValueGuard` | `true` | Drop a primitive write whose value is `Object.is`-equal to the current one before anything is enqueued โ€” bindings and `$watch` effectively fire on change only; reference types always pass. `false` lets equal writes through and makes `$watch`'s `prev` `undefined` |
3187
+ | `enableDirectionalInitialSync` | `true` | Direction-aware binding authority (`#init=` / `#sync=` binding modifiers) โ€” see [Binding Authority](#binding-authority-init--sync). `false` opts out: `#init=` / `#sync=` throw, and state wins every initial sync |
3188
+
3189
+ - Each key is a boolean. An unknown key, a value that is not a boolean, or a `$behavior` that is not an object โ€” `null` and an array included โ€” throws (`#44`).
3190
+ - It applies to the tree of the `<wcs-state>` that declares it, its volumes included. **Each root state declares its own** โ€” the page's root, a root `<wcs-state>` in a shadow root, a mounted component, a DCC: nothing is inherited from a host. A volume may not declare one ([`mount=`](#mounting-additional-state-mount)).
3191
+ - A re-set (`setInitialState()` on an initialized element) may not change it: that throws `#45` โ€” create the element again. A re-set state without `$behavior` is compared with the defaults, so repeat the declaration in it.
3192
+ - It works on `/auto` pages and in JSON states (`"$behavior": { "enableMustache": false }`). Under SSR the server reads the same state, so server and client agree.
3193
+
3194
+ ### `$features`
3195
+
3196
+ `$features: ["temporal", "formats"]` names the add-ons a state needs. The split auto entry loads the missing ones before it builds the state; the other entries check that they are installed, and fail the state with `[wcs/feature-not-installed]` otherwise. A value that is not an array throws `#46`, and a volume may not declare one. The names, and the root `features=` attribute that the split auto entry reads before any state: [The split auto entry](#the-split-auto-entry).
2921
3197
 
2922
3198
  ### Locale
2923
3199
 
2924
- Four filters format by locale โ€” `locale`, `date`, `time`, `datetime`. They read
2925
- `config.locale`, which **defaults to `<html lang>`**:
3200
+ Four filters format by locale โ€” `locale`, `date`, `time`, `datetime` (the `formats` add-on). They read
3201
+ the page locale (`getConfig().locale`), which **defaults to `<html lang>`**:
2926
3202
 
2927
3203
  ```html
2928
3204
  <html lang="ja-JP">
@@ -2931,12 +3207,13 @@ Four filters format by locale โ€” `locale`, `date`, `time`, `datetime`. They rea
2931
3207
 
2932
3208
  Nothing else is needed; `<html lang>` is the standard place to record a page's
2933
3209
  language, and making it the default keeps one source of truth. It also means the
2934
- CDN one-liner can set the locale at all โ€” `auto` calls `bootstrapState()` with no
2935
- arguments, so before this there was no way in. An explicit
2936
- `bootstrapState({ locale })` still wins, and an invalid BCP-47 tag is reported
2937
- and ignored rather than left to throw inside `Intl`.
3210
+ CDN one-liner, which takes no options, can set the locale. `<html lang>` is read when
3211
+ the module is evaluated, so write it in the markup. An explicit
3212
+ `bootstrapState({ locale })` still wins. A tag `Intl` does not take (`<html lang="en_US">`)
3213
+ is reported once (`#48`) and the filters use `"en"`, rather than throwing inside `Intl`;
3214
+ `getConfig().locale` keeps returning what was set.
2938
3215
 
2939
- **Changing `config.locale` later does not re-render anything.** It is a global
3216
+ **Changing the locale later does not re-render anything.** It is a global
2940
3217
  setting, not state, so it is not part of the dependency graph. The filters do
2941
3218
  read it on every application rather than capturing it when the binding is built,
2942
3219
  which means a binding that re-renders for its own reasons will pick up the new
@@ -2965,22 +3242,19 @@ CSP all resolve against it), and two alternatives were weighed โ€” the router re
2965
3242
  Read [docs/i18n-design.md](../../docs/i18n-design.md) ยง9-1 before choosing a
2966
3243
  different shape; `examples/router-i18n` is the reference layout.
2967
3244
 
2968
- > These three are **architecture-hardening** features; their normative reference is
2969
- > `docs/architecture-hardening/`. `enablePropagationContext` defaults **on** โ€” its
2970
- > write-path cost is near-zero for one-way bindings (only echo-capable two-way
2971
- > wires do the causal bookkeeping) โ€” with the flag kept as a permanent opt-out.
2972
- > `enableDirectionalInitialSync` also defaults **on**: it assigns per-property
3245
+ > `$behavior.enableDirectionalInitialSync` and `enableContractAnalyzer` are **architecture-hardening**
3246
+ > features; their normative reference is `docs/architecture-hardening/`.
3247
+ > `enableDirectionalInitialSync` defaults **on**: it assigns per-property
2973
3248
  > initial-sync authority (an output-only `wcBindable` member reads its initial value
2974
- > elementโ†’state; two-way / input members keep stateโ†’element). Its setup-path cost is
2975
- > under 5% of initial render (the producer-value observer is only registered for
2976
- > echo-capable two-way wires), and the flag is a permanent opt-out. `enableContractAnalyzer`
3249
+ > elementโ†’state; two-way / input members keep stateโ†’element), and the key is a permanent
3250
+ > opt-out. `enableContractAnalyzer`
2977
3251
  > is opt-in (default `false`, zero runtime cost when off); when on, the exported
2978
3252
  > `analyzeContract()` API reports drift between a live `static wcBindable` surface and
2979
3253
  > a sidecar manifest for dev-time diagnostics.
2980
3254
 
2981
3255
  ## Testing Your Page
2982
3256
 
2983
- A page built on `<wcs-state>` is plain DOM, so it can be tested headlessly with [happy-dom](https://github.com/capricorn86/happy-dom) โ€” no browser, no build step, no test-only API. Three recipes follow; every one of them runs as written (recipe 1 is pinned by [`__tests__/readme.testingRecipe.test.ts`](__tests__/readme.testingRecipe.test.ts), which executes the same lines).
3257
+ A page built on `<wcs-state>` is plain DOM, so it can be tested headlessly with [happy-dom](https://github.com/capricorn86/happy-dom) โ€” no browser, no build step, no test-only API. Three recipes follow; every one of them runs as written.
2984
3258
 
2985
3259
  Want it as one import? [`@wcstack/testing`](../testing/README.md) packages recipe 1 as `mount()` / `settle()` / `fire()` (and waits for `<wcs-router>` too). The bare recipes below stay valid without it.
2986
3260
 
@@ -3049,11 +3323,10 @@ it("renders, re-renders, and runs handlers", async () => {
3049
3323
 
3050
3324
  To drive the page the way a user does, keep the state inline (methods included) and dispatch DOM events; a `data-wcs="onclick: up"` handler runs on `button.click()`, and the DOM reflects the write after one `settle()`.
3051
3325
 
3052
- - `getBindingsReady(root)` resolves once every binding under `root` (a `document` or a shadow root) is built, and rejects if binding initialization fails (v1.26+) or if the root's `<wcs-state>` failed to initialize โ€” a root that never loaded reports the failure instead of "ready".
3326
+ - `getBindingsReady(root)` resolves once the bindings under `root` (a `document` or a shadow root) are built, and rejects if the root's `<wcs-state>` failed to initialize โ€” a root that never loaded reports the failure instead of "ready" ([Initialization failures](#initialization-failures)). It does not wait for `$connectedCallback`; await `connectedCallbackPromise` for that.
3053
3327
  - Updates settle on the microtask queue; a single `setTimeout(0)` after a write is enough.
3054
3328
  - `state.items = [...state.items, "cherry"]` is the reactive form โ€” `state.items.push()` is not observed (same rule as in handlers).
3055
3329
  - Under happy-dom, `customElements.define` upgrades existing nodes by **replacing** them; "a value reaches the same node after a late define" cannot be asserted headlessly. Event timing differences between happy-dom and real browsers are the other blind spot โ€” keep one browser e2e (Playwright) for those.
3056
- - happy-dom's `textContent` setter turns a numeric `0` into an empty string (browsers render `"0"`), so a `textContent: count` binding reads `""` at zero in this recipe. Assert on the state value, or use `@wcstack/testing`, whose `mount()` shims the setter.
3057
3330
 
3058
3331
  ### 2. Bare Node (no vitest)
3059
3332
 
@@ -3096,10 +3369,10 @@ it("matches the rendered snapshot", async () => {
3096
3369
 
3097
3370
  ## TypeScript Support
3098
3371
 
3099
- `defineState()` wraps your state object and provides type-safe `this` inside methods and getters โ€” with zero runtime cost (identity function).
3372
+ `defineState()` wraps your state object and provides type-safe `this` inside methods and getters โ€” with zero runtime cost (identity function). Import it from **`@wcstack/state/define`**, which carries `defineState` and the types and nothing else (69 B):
3100
3373
 
3101
3374
  ```typescript
3102
- import { defineState } from '@wcstack/state';
3375
+ import { defineState } from '@wcstack/state/define';
3103
3376
 
3104
3377
  export default defineState({
3105
3378
  count: 0,
@@ -3117,6 +3390,8 @@ export default defineState({
3117
3390
  });
3118
3391
  ```
3119
3392
 
3393
+ `@wcstack/state` and `/core` export `defineState` too, but a bundler cannot drop the engine from those entries: a state file that imports `defineState` from `@wcstack/state` keeps the whole engine (about 21 KB gzip) in its bundle. That is harmless where the bundle runs the engine anyway, and wasteful where the state file is bundled on its own (the runtime loaded from a CDN, a type-check or test build) โ€” so prefer `/define`.
3394
+
3120
3395
  Utility types `WcsPaths<T>` and `WcsPathValue<T, P>` are also exported for advanced use cases. See [docs/define-state.md](docs/define-state.md) for full documentation.
3121
3396
 
3122
3397
  `defineState()` types the state file. To carry those types into the HTML, [`@wcstack/typescript`](../typescript/README.md) adds two CLIs: `wcs-schema` writes the `stateSchema` sidecar that `@wcstack/lint` and the VS Code extension validate `data-wcs` paths against, and `wcs-tsc` runs the TypeScript compiler over inline `<script type="module">` state. The whole story is in [docs/typescript.md](../../docs/typescript.md).
@@ -3125,140 +3400,98 @@ Utility types `WcsPaths<T>` and `WcsPathValue<T, P>` are also exported for advan
3125
3400
 
3126
3401
  ### `bootstrapState()`
3127
3402
 
3128
- Initialize the state system. Registers `<wcs-state>` custom element and sets up DOM content loaded handler.
3129
-
3130
3403
  ```javascript
3131
3404
  import { bootstrapState } from '@wcstack/state';
3132
- bootstrapState();
3405
+ bootstrapState(config?, registry?);
3133
3406
  ```
3134
3407
 
3408
+ Installs every add-on (from `@wcstack/state`; the `/core` entry's `bootstrapState()` installs none โ€” call `installFeatures([...])` first), applies `config` ([`bootstrapState()` options](#bootstrapstate-options)), and registers `<wcs-state>` โ€” and the installed add-ons' tags, `<wcs-ssr>` โ€” in `registry`: the global `customElements` by default, or a scoped `CustomElementRegistry`, which does not inherit the global one's definitions. The `/auto` entries do the same with no options.
3409
+
3135
3410
  ### Other exports
3136
3411
 
3137
3412
  | Export | Description |
3138
3413
  |---|---|
3139
- | `getBindingsReady(root)` | Resolves once every binding under `root` (a `document` or a shadow root) is built; rejects if binding initialization fails, or if the root's state element failed to initialize |
3140
- | `buildBindings(root)` | Build the bindings under a `document` or `ShadowRoot` explicitly โ€” what the first `<wcs-state>` registered on a root schedules for it |
3414
+ | `getBindingsReady(root)` | Resolves once the bindings under `root` (a `document` or a shadow root) are built, without waiting for `$connectedCallback`; rejects if the root's `<wcs-state>` failed to initialize โ€” see [Initialization failures](#initialization-failures) |
3415
+ | `buildBindings(root)` | Waits for the bindings under a `document` or `ShadowRoot`, the same as `getBindingsReady(root)`: a root's bindings are built when its `<wcs-state>` has loaded its state |
3141
3416
  | `getConfig()` | The current configuration (read-only view) |
3142
- | `defineState(obj)` | Identity function that types `this` inside methods and getters โ€” see [TypeScript Support](#typescript-support) |
3417
+ | `defineState(obj)` | Identity function that types `this` inside methods and getters โ€” see [TypeScript Support](#typescript-support). In a state file, import it from `@wcstack/state/define` |
3418
+ | `setTrustedTypesPolicy(policy)` / `getTrustedTypesPolicy()` / `TRUSTED_TYPES_POLICY_SLOT` | The Trusted Types policy the HTML sinks use โ€” see [Security](#security) and [docs/csp.md ยง7](../../docs/csp.md) |
3143
3419
  | `VERSION` | The package version; stamped into `<wcs-ssr>` and compared on hydration |
3144
- | `getWcsManifest()` / `WCS_MANIFEST_VERSION` | Machine-readable manifest of the binding syntax, built-in filters and reserved names โ€” derived from the implementation, consumed by `@wcstack/lint` and the VS Code extension |
3420
+ | `Ssr` | The `<wcs-ssr>` element class (`ISsrElement`: `version`, `stateData`, `templates`, `getTemplate()`, `verifyVersion()`; `hydrateProps` is always empty) |
3421
+ | `getWcsManifest()` / `WCS_MANIFEST_VERSION` | Machine-readable manifest of the binding syntax, built-in filters, reserved names, the `$behavior` options (`behaviorOptions`) and the add-on names (`features`) โ€” derived from the implementation, consumed by `@wcstack/lint` and the VS Code extension |
3145
3422
  | `builtinFilterMeta` | Argument and result metadata for every built-in filter |
3423
+ | `builtinFilterAliases` | Kept for the tools that read it; empty (4.0 has no filter aliases) |
3146
3424
  | `analyzeContract()` | Dev-time contract analyzer; a no-op unless `enableContractAnalyzer` is on |
3147
3425
 
3148
- Subpath entries for tooling: `@wcstack/state/parser` (the `data-wcs` parser as a DOM-free pure function), `@wcstack/state/manifest`, and `@wcstack/state/wcs-manifest.json` (the manifest as a prebuilt JSON file).
3426
+ `@wcstack/state/core` exports `bootstrapState`, `installFeatures`, `getBindingsReady`, `buildBindings`, `getConfig`, the Trusted Types functions, `defineState` and `VERSION`; each `@wcstack/state/features/<name>` exports its add-on as the default export. Subpath entries for tooling: `@wcstack/state/parser` (the `data-wcs` parser as a DOM-free pure function), `@wcstack/state/manifest`, and `@wcstack/state/wcs-manifest.json` (the manifest as a prebuilt JSON file). The type `IStateElement` is exported too.
3149
3427
 
3150
3428
  ### `<wcs-state>` Element
3151
3429
 
3152
3430
  | Attribute | Description |
3153
3431
  |---|---|
3154
3432
  | `mount` | Static tree path to graft this state onto the root tree as a **volume** (v2 โ€” replaces the removed `name` attribute; one state tree per root) |
3155
- | `state` | ID of a `<script type="application/json">` element in the document (looked up with `document.getElementById`, so a script inside a shadow root is not found) |
3433
+ | `state` | ID of a `<script type="application/json">` โ€” looked up in the element's own root first (a shadow root), then in the document; only a JSON script of that id counts |
3156
3434
  | `src` | URL to `.json` or `.js` file |
3157
3435
  | `json` | Inline JSON string |
3158
3436
  | `bind-component` | Property name for web component binding |
3159
3437
  | `enable-ssr` | Opt into SSR: the server emits `<wcs-ssr>` hydration data for this state and the client hydrates from it instead of re-rendering โ€” see [Server-Side Rendering](#server-side-rendering) |
3438
+ | `features` | On the document's root `<wcs-state>`, read only by the split auto entry: the add-ons to load before `<wcs-state>` is defined โ€” see [The split auto entry](#the-split-auto-entry) |
3160
3439
 
3161
3440
  ### IStateElement
3162
3441
 
3163
3442
  | Property / Method | Description |
3164
3443
  |---|---|
3165
- | `initializePromise` | Resolves when state is fully initialized โ€” and also **when initialization fails**, so one element's failure never blocks the rest of the page's bindings; the error is delivered on `connectedCallbackPromise` |
3166
- | `connectedCallbackPromise` | Resolves once `connectedCallback` has completed (state loaded, `$connectedCallback` run) โ€” what the testing recipes await. A **root** element that fails to initialize **rejects** it with the original error, unwrapped, and reports the failure once with `console.error`: an invalid `$` declaration, a source it cannot load, the SSR data merge, a DCC or `bind-component` setup error, or a second root `<wcs-state>` on the same root node (that second element stays unregistered but keeps the state it loaded, so remove it; moving a healthy element in the DOM is not a duplicate and is never refused). Two sources are not failures in 3.x and resolve it instead: a `src="*.json"` that cannot be fetched or parsed (logged with `console.error`, with its URL and the HTTP status or the error; the element starts with an empty state, and a volume grafts `{}`), and a `state="<id>"` with no `<script type="application/json">` of that id in the document (one `console.warn`; the state starts empty โ€” the lookup is `document.getElementById`, so a script inside a shadow root is not found). 4.0 rejects in both cases; for `state=` it searches the element's own root first, then the document, and rejects only when the id is in neither. A **volume** (`<wcs-state mount="โ€ฆ">`) never rejects it โ€” a volume failure resolves it instead, and some volume failures report nothing of their own: the error leaves as the `connectedCallback` promise that custom-element reactions discard, which a browser console shows as "Uncaught (in promise)" but nothing awaiting these promises (a test recipe, `renderToString()`) ever sees. Detaching an element while its source is still loading rejects nothing โ€” that connection just ends, and re-appending the element (row pooling) initializes it and resolves normally. For the exact behaviour of any single failure site, read `__tests__/integration.initFailureDiagnostics.test.ts`: it pins every case |
3167
- | `listPaths` | Set of paths used in `for` loops |
3168
- | `getterPaths` | Set of paths defined as getters |
3169
- | `setterPaths` | Set of paths defined as setters |
3170
- | `createState(mutability, callback)` | Create a state proxy (`"readonly"` or `"writable"`) |
3171
- | `createStateAsync(mutability, callback)` | Async version of `createState` |
3172
- | `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 |
3173
- | `nextVersion()` | Increment and return version number |
3444
+ | `initializePromise` | Resolves once the state is loaded and the bindings are built โ€” and also **when initialization fails**, so one element's failure never blocks the rest of the page's bindings; the error is delivered on `connectedCallbackPromise` |
3445
+ | `connectedCallbackPromise` | Resolves once `connectedCallback` has completed (state loaded, bindings built, `$connectedCallback` run) โ€” what the testing recipes await. A root element that fails to initialize **rejects** it with the original error, unwrapped, and so does one whose `$connectedCallback` fails, a DCC definition and a component mount that fail; a volume always resolves it. See [Initialization failures](#initialization-failures) |
3446
+ | `createState(mutability, callback)` | Create a state proxy (`"readonly"` or `"writable"`); throws `state is not initialized` (`#15`) before the state is loaded |
3447
+ | `createStateAsync(mutability, callback)` | Async version of `createState`. A readonly proxy stays readonly across the callback's `await`s, and refuses the writes of `$setAll` and `$resolve(path, indexes, value)` too |
3448
+ | `setInitialState(state)` | Set state programmatically โ€” see below |
3449
+
3450
+ `setInitialState(state)` before initialization supplies the initial state. On an initialized element it replaces the whole state and re-applies every binding to the new state before it returns. A re-set is not a write: it fires no `$watch` handler and no `$renderedCallback`. A path the new state lacks reads `undefined`, at any depth: a binding to it is emptied (an element input keeps its own value, as for any `undefined` value), a list whose key the new state lacks renders no rows, and a getter that reads it gets `undefined`. A top-level key the old state had stays that way until it is written; a key no state had fails to read, as anywhere (`#301`). This is kept on the element: the new state object is not written to, so a frozen state (Immer, Redux Toolkit) can be re-set too. One gap: a getter on a class state's prototype is not enumerable, so when the new state drops it, the bindings to it fail to apply (reported, their text left as it was). A list that cannot be read (its getter throws) is reported as its `for` failure, and the re-set goes on. 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. It throws, and leaves the old state in place, when the new state's `$behavior` differs (`#45`) or a declaration has the wrong shape. It 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 component (`bind-component`) or DCC definition, on a tree with grafted volumes or mounted components, and on an element that failed to initialize (`#14`) โ€” such an element cannot be re-armed; remove it and create a new one. A root whose `$connectedCallback` failed after its bindings were built can be re-set. After a re-set, `Object.keys(this)`, `in`, `delete` and `JSON.stringify(this)` still see the old state object (a known limitation of this version).
3174
3451
 
3175
3452
  ## Architecture
3176
3453
 
3177
3454
  ```
3178
- bootstrapState()
3179
- โ””โ”€โ”€ registerComponents() // Register <wcs-state> custom element
3455
+ bootstrapState(config?, registry?)
3456
+ โ”œโ”€โ”€ installFeatures(every add-on) // @wcstack/state and /auto; /core installs none
3457
+ โ”œโ”€โ”€ setConfig(config) // checks every option first, then applies them
3458
+ โ””โ”€โ”€ define(registry) // <wcs-state> (+ the add-ons' tags), the binder
3180
3459
 
3181
3460
  <wcs-state> connectedCallback
3182
- โ”œโ”€โ”€ one of, by placement:
3183
- โ”‚ โ”œโ”€โ”€ _initializeDCC() // under a data-wc-definition host: define the DCC class
3184
- โ”‚ โ”œโ”€โ”€ _initializeVolume() // mount=: graft this volume onto the root tree
3185
- โ”‚ โ”œโ”€โ”€ _initializeBindWebComponent() // bind-component: alias the host's tree at the mount point
3186
- โ”‚ โ””โ”€โ”€ _initialize() // root: load state (state attr / src / json / script / API)
3187
- โ”‚ โ””โ”€โ”€ setStateElement() // Register to WeakMap<Node, IStateElement> โ€” one tree per root
3188
- โ”‚ โ””โ”€โ”€ (first registration per rootNode)
3189
- โ”‚ โ””โ”€โ”€ queueMicrotask โ†’ buildBindings()
3190
- โ”œโ”€โ”€ _callStateConnectedCallback() // Call $connectedCallback if defined
3191
-
3192
- buildBindings(root)
3193
- โ”œโ”€โ”€ waitForStateInitialize() // Wait for all <wcs-state> initializePromise
3194
- โ”œโ”€โ”€ convertMustacheToComments() // {{ }} โ†’ comment nodes
3195
- โ”œโ”€โ”€ collectStructuralFragments() // Collect for/if templates
3196
- โ””โ”€โ”€ initializeBindings() // Walk DOM, parse data-wcs, set up bindings
3461
+ โ”œโ”€โ”€ taken by an add-on (scopes) โ€” not a root:
3462
+ โ”‚ โ”œโ”€โ”€ volume (mount=) // graft its state onto the root tree at the mount path
3463
+ โ”‚ โ”œโ”€โ”€ DCC definition // define the tag; each instance has a root of its own
3464
+ โ”‚ โ””โ”€โ”€ component (bind-component) // an engine of its own, wired to the host's
3465
+ โ””โ”€โ”€ root:
3466
+ โ”œโ”€โ”€ load the state // state= / src= / json= / inline script / setInitialState()
3467
+ โ”œโ”€โ”€ load or check $features
3468
+ โ”œโ”€โ”€ new Engine(state) // $behavior, declarations, accessors
3469
+ โ”œโ”€โ”€ mount(engine, root) // walk the root once: plans, views, bindings โ€” and render
3470
+ โ”œโ”€โ”€ bindings built // initializePromise, getBindingsReady()
3471
+ โ””โ”€โ”€ $connectedCallback // then connectedCallbackPromise
3197
3472
  ```
3198
3473
 
3199
3474
  ### Reactivity Flow
3200
3475
 
3201
- 1. State changes via Proxy `set` trap โ†’ `setByAddress()`
3202
- 2. Address resolved โ†’ updater enqueues absolute address
3203
- 3. Dependency walker invalidates (dirties) downstream caches
3204
- 4. Updater applies changes to bound DOM nodes via `applyChangeFromBindings()`
3476
+ 1. A write through the state proxy (`this["user.name"] = โ€ฆ`) resolves the path to a pattern and a row, and writes the data.
3477
+ 2. The dependency walk dirties the getter occurrences that read it, and queues the bindings on that path and under it.
3478
+ 3. On a microtask the drain re-syncs the lists, renders their rows, and applies the queued bindings โ€” through the view-transition arbiter, when the page has one.
3479
+ 4. After the drain: `$renderedCallback`, the binding failures, then the add-ons (`$watch` handlers, `$stream` restarts).
3205
3480
 
3206
3481
  ### State Address System
3207
3482
 
3208
- Paths like `users.*.name` are decomposed into:
3483
+ A path like `users.*.name` is addressed in two parts:
3484
+
3485
+ - **Pattern** โ€” one per path spelled with `*` (`users.*.name`): its segments, its depth (the number of `*`), its parent, its getter or setter, the getters that read it, and the bindings registered on it at the root.
3486
+ - **List and row** โ€” a list for each array value a `for:`, a `$getAll` or a `$watch` reads, and a row for each element of it: the element, its position, its getter cache, the bindings inside it and its nested lists. A concrete path (`users.0.name`) is a pattern plus a row; dropping the row drops everything that hangs off it.
3209
3487
 
3210
- - **PathInfo** โ€” static path metadata (segments, wildcard count, parent path)
3211
- - **ListIndex** โ€” runtime loop index chain
3212
- - **StateAddress** โ€” combination of PathInfo + ListIndex
3213
- - **TreePath / AbsoluteStateAddress** โ€” a PathInfo pinned to the state element that owns the tree, plus its ListIndex. Mounted components and volumes translate their relative paths onto the host tree at this level; v2 has one tree per root, so an address carries no state name
3488
+ A mounted component runs an engine of its own, whose mounted keys read and write the host engine's paths; a volume's getters, setters and methods become accessors of the root tree at its mount path. There is one tree per root, so an address carries no state name.
3214
3489
 
3215
3490
  ## Performance
3216
3491
 
3217
- Measured with the repository's [js-framework-benchmark](https://github.com/krausest/js-framework-benchmark)-style
3218
- drivers (`e2e/bench/jsfb-verify.mjs`, `e2e/bench/memory-profile.mjs`) against the
3219
- standard 1,000 / 10,000-row table page โ€” headless Chromium, medians, both
3220
- implementations measured back-to-back in the same session. `@wcstack/state`
3221
- passes the official keyed-mode classification while recycling row DOM through a
3222
- bounded pool (up to 1,000 rows).
3492
+ The 4.0 engine is a rewrite, and its per-operation numbers have not yet been recorded with the repository's drivers: they are taken with the 4.0 build as part of the release. During development, the official [js-framework-benchmark](https://github.com/krausest/js-framework-benchmark) measured the 4.0 engine's CPU-weighted geometric mean at 1.08โ€“1.11, against 1.21โ€“1.25 for [`@wcstack/signals`](../signals/) and 1.51 for `@wcstack/state` 3.3.0 (lower is faster).
3223
3493
 
3224
- | Duration (ms, median) | `@wcstack/state` | [`@wcstack/signals`](../signals/) |
3225
- |---|---|---|
3226
- | create 1,000 rows | 25.2 | 9.5 |
3227
- | replace all 1,000 rows | 18.8 | 12.5 |
3228
- | update every 10th of 10,000 | 11.4 | 4.7 |
3229
- | select row | 0.1 | 0.4 |
3230
- | swap 2 rows | 0.9 | 0.4 |
3231
- | remove row | 2.8 | 0.6 |
3232
- | append 1,000 to 10,000 | 48.6 | 14.2 |
3233
- | clear 10,000 rows | 54.6 | 52.2 |
3234
-
3235
- | Heap after forced GC (MB) | `@wcstack/state` | `@wcstack/signals` |
3236
- |---|---|---|
3237
- | page ready | 1.0 | 0.6 |
3238
- | after create 1,000 | 5.6 | 3.5 |
3239
- | after 5ร— replace 1,000 | 6.4 | 3.7 |
3240
- | after create 10,000 | 35.1 | 18.0 |
3241
- | after create 10,000 + clear | 13.2 | 1.9 |
3242
-
3243
- How to read this, honestly:
3244
-
3245
- - Interactive operations (select / swap / remove) run in a few milliseconds or
3246
- less, and clearing a huge list matches the signals implementation.
3247
- - Creating and appending rows costs ~2.5โ€“3.5ร— `@wcstack/signals`. That is the
3248
- price of the declarative binding ledger this package builds per row โ€” the same
3249
- ledger that powers `data-wcs` inspection, DevTools wiring, and SSR hydration.
3250
- The two packages interoperate, so a hot list can be rendered with signals'
3251
- `For` while the rest of the page stays declarative.
3252
- - The heap retained after a clear is the bounded row pool that makes the next
3253
- list population cheap.
3254
-
3255
- Absolute numbers are from one development machine, taken at v1.21.6 + the
3256
- clear-leak fix in PR#87. The v2.0 mount work was gated on the same drivers by
3257
- same-session A/B runs and stayed within run-to-run noise
3258
- ([docs/state-mount-impl-plan.md](../../docs/state-mount-impl-plan.md) ยง2-2 and
3259
- slice 27), so the table has not been re-taken; absolute values swing by ยฑ20%
3260
- with machine state, and the drivers in `e2e/bench/` reproduce the comparison on
3261
- your own hardware.
3494
+ To compare on your own hardware, run the drivers in `e2e/bench/` (`jsfb-verify.mjs`, `memory-profile.mjs`): they measure the standard 1,000 / 10,000-row table page in headless Chromium, both implementations back-to-back in the same session, as medians. Absolute values swing by ยฑ20% with machine state.
3262
3495
 
3263
3496
  ## Server-Side Rendering
3264
3497
 
@@ -3295,22 +3528,30 @@ const html = await renderToString(template, {
3295
3528
  });
3296
3529
  ```
3297
3530
 
3298
- That's it. The client-side `@wcstack/state` automatically detects the `<wcs-ssr>` element, restores state from the JSON snapshot, and resumes reactivity without re-rendering.
3531
+ That's it. The client-side `@wcstack/state` finds the `<wcs-ssr>` element, takes the server's data from its snapshot, and binds the server's DOM where it is instead of rendering it again.
3299
3532
 
3300
3533
  ### How It Works
3301
3534
 
3302
3535
  | Phase | What happens |
3303
3536
  |-------|-------------|
3304
- | **Server** | `renderToString()` runs your template in happy-dom, executes `$connectedCallback` (including `fetch()`), applies all bindings, and outputs rendered HTML with a `<wcs-ssr>` element containing hydration data |
3305
- | **Client** | `<wcs-state enable-ssr>` loads state from `<wcs-ssr>` JSON, skips `$connectedCallback`, and `hydrateBindings()` wires up reactivity on the existing DOM |
3306
- | **Fallback** | If server/client versions mismatch, or a server-rendered `for:` row sits inside another `for:` / `if:` / `elseif:` / `else:` block (hydrating those is a known limitation), the SSR DOM is cleaned up and `buildBindings()` runs a full client-side render. Either case logs one `console.warn` saying why |
3537
+ | **Server** | `renderToString()` runs your template in happy-dom, executes `$connectedCallback` (including `fetch()`), applies all bindings, and outputs rendered HTML with a `<wcs-ssr>` element before the `<wcs-state>`. Rows, branches and text bindings are marked with comments. `$watch` handlers do not run, and streams keep their `initial` value |
3538
+ | **Client** | `<wcs-state enable-ssr>` loads its state from its own source, takes the snapshot's data over it (getters, setters and methods stay), skips `$connectedCallback`, and binds the server's DOM in place. Rows and branches are adopted where they are โ€” a custom element in them is connected once and never disconnected and connected again โ€” and get their bindings. A server row that no longer fits its template is rendered again in its place; rows and branches the client does not render (fewer rows, another branch) go as soon as the page is bound. A volume adopts the server's data at its mount path |
3539
+ | **Version mismatch** | A snapshot whose `<wcs-ssr version>` has another major.minor is discarded with one `console.warn`, and the page renders on the client as if there were no server: the state loads from its own source, `$connectedCallback` runs, and the server's rows, branches and markers give way to the templates they were rendered from |
3307
3540
 
3308
3541
  ### What `enable-ssr` Does
3309
3542
 
3310
3543
  | Context | Behavior |
3311
3544
  |---------|----------|
3312
- | **Server** (`renderToString`) | Generates `<wcs-ssr>` with state JSON, template fragments, and property data |
3313
- | **Client** (hydration) | Reads `<wcs-ssr>`, restores state, skips `$connectedCallback`, hydrates bindings on existing DOM |
3545
+ | **Server** (`renderToString`) | Generates `<wcs-ssr>` with the version, the state's data as JSON, and the page-level templates |
3546
+ | **Client** (hydration) | Reads `<wcs-ssr>`, restores the data, skips `$connectedCallback`, adopts the server's rows and branches and binds them |
3547
+
3548
+ Rules:
3549
+
3550
+ - **Deploy `@wcstack/server` and the client together**, of the same major.minor. A 4.0 client renders the output of a 3.x server again on the client (`<wcs-ssr version="3.5.0"> does not match 4.0.0: its snapshot is discarded, and the page renders on the client from its own state.`) โ€” the page works, but what the server did is done again, and data that `$connectedCallback` fetches is fetched again. In the output of a 3.x server up to 3.5.2, a text binding outside the templates (`{{ }}` or `<!--@@: -->` at page level or in a Light DOM component's content) keeps only its path, so it loses its filters, and the warning says so; from 3.5.3 the markers keep the whole expression, except one an HTML comment cannot hold (with `--` in it, such as `|defaults('--')`), which keeps only its path. Purge HTML cached from a 3.x server when you switch. A 4.0 server with a 3.x client is not supported. The details are in the [migration guide ยง3.6](https://github.com/wcstack/wcstack/blob/main/docs/migration-v4.md).
3551
+ - **The snapshot is JSON.** `Date`, `Set`, `Map` and class instances do not survive it: they arrive as strings or plain objects and replace the client's values. Keep JSON values in SSR state and derive the rest in getters (`get created() { return new Date(this.createdIso); }`).
3552
+ - **Keep the comments in the output** โ€” removing them breaks hydration ([Security](#security)). Do not post-process the output based on its markers either: their format is not an API.
3553
+ - `outerHTML:` / `outerText:` are applied on the client, not on the server: the server output contains the element as written, so the value is not in the HTML that search engines or no-JS views see.
3554
+ - Children that a Light DOM custom element bound at page level renders from a value are not in the server output; the client renders them. Children you wrote stay in it.
3314
3555
 
3315
3556
  See [`@wcstack/server` README](../server/README.md) for full API documentation.
3316
3557