@wcstack/state 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.ja.md +140 -83
  2. package/README.md +141 -84
  3. package/dist/auto.min.js +1 -1
  4. package/dist/auto.min.js.map +1 -1
  5. package/dist/index.d.ts +74 -8
  6. package/dist/index.esm.js +1 -1
  7. package/dist/index.esm.js.map +1 -1
  8. package/dist/manifest.d.ts +28 -5
  9. package/dist/manifest.esm.js +253 -246
  10. package/dist/parser.d.ts +16 -2
  11. package/dist/parser.esm.js +223 -50
  12. package/dist/split/chunks/binder.js +1 -1
  13. package/dist/split/chunks/binder.js.map +1 -1
  14. package/dist/split/chunks/checkDependency.js +1 -1
  15. package/dist/split/chunks/checkDependency.js.map +1 -1
  16. package/dist/split/chunks/declarationAliases.js +1 -1
  17. package/dist/split/chunks/declarationAliases.js.map +1 -1
  18. package/dist/split/chunks/declarationHooks.js +2 -0
  19. package/dist/split/chunks/declarationHooks.js.map +1 -0
  20. package/dist/split/chunks/diagnosticsHooks.js.map +1 -1
  21. package/dist/split/chunks/featureBridge.js +2 -0
  22. package/dist/split/chunks/featureBridge.js.map +1 -0
  23. package/dist/split/chunks/filterRegistry.js +1 -1
  24. package/dist/split/chunks/filterRegistry.js.map +1 -1
  25. package/dist/split/chunks/getAllPropertyDescriptors.js +1 -1
  26. package/dist/split/chunks/getAllPropertyDescriptors.js.map +1 -1
  27. package/dist/split/chunks/lifecycleHooks.js +1 -1
  28. package/dist/split/chunks/pathDiagnostics.js +1 -1
  29. package/dist/split/chunks/pathDiagnostics.js.map +1 -1
  30. package/dist/split/chunks/registerComponents.js +1 -1
  31. package/dist/split/chunks/registerComponents.js.map +1 -1
  32. package/dist/split/chunks/setAll.js +1 -1
  33. package/dist/split/chunks/setAll.js.map +1 -1
  34. package/dist/split/chunks/version.js +1 -1
  35. package/dist/split/core.d.ts +64 -2
  36. package/dist/split/core.js +1 -1
  37. package/dist/split/core.js.map +1 -1
  38. package/dist/split/features/devtools.js +1 -1
  39. package/dist/split/features/devtools.js.map +1 -1
  40. package/dist/split/features/diagnostics.js +1 -1
  41. package/dist/split/features/diagnostics.js.map +1 -1
  42. package/dist/split/features/formats.js +1 -1
  43. package/dist/split/features/formats.js.map +1 -1
  44. package/dist/split/features/recursion.js +1 -1
  45. package/dist/split/features/recursion.js.map +1 -1
  46. package/dist/split/features/scopes.js +1 -1
  47. package/dist/split/features/scopes.js.map +1 -1
  48. package/dist/split/features/ssr.js +1 -1
  49. package/dist/split/features/ssr.js.map +1 -1
  50. package/dist/split/features/temporal.js +1 -1
  51. package/dist/split/features/temporal.js.map +1 -1
  52. package/dist/wcs-manifest.json +36 -21
  53. package/package.json +1 -1
  54. package/dist/split/chunks/mount.js +0 -2
  55. package/dist/split/chunks/mount.js.map +0 -1
  56. package/dist/split/chunks/watchRuntime.js +0 -2
  57. package/dist/split/chunks/watchRuntime.js.map +0 -1
package/README.md CHANGED
@@ -88,7 +88,7 @@ This separates UI and state with **no JavaScript intermediary**. You can:
88
88
 
89
89
  - Redesign the UI without touching state logic — as far as the logic does not hang off what is rendered: a live binding is one of the three [demand roots](#demand-roots--what-makes-a-getter-run), so an element you think of as display-only can be the page's only subscription
90
90
  - Refactor state structure and only update path strings
91
- - Read the HTML and know every binding; the dependencies that are not in the HTML (`$watch`, `$streams`, `$scan`) are all declared in one place, the state
91
+ - Read the HTML and know every binding; the dependencies that are not in the HTML (`$watch`, `$stream`, `$scan`) are all declared in one place, the state
92
92
 
93
93
  The path contract works like a URL in a REST API — a simple string that both sides agree on, with no shared code between them. It's the natural result of building on HTML's declarative nature rather than inventing a template language on top of JavaScript.
94
94
 
@@ -132,12 +132,12 @@ Every row is a section of this README. Unless it appears under [Where the neighb
132
132
  | **Aggregation and bulk write** | `$getAll` / `$setAll` / `$resolve` read and write across `items.*.price` without rebuilding the array | [Proxy APIs](#proxy-apis) |
133
133
  | **Recursive paths** | `$recursion` declares where a tree's shape repeats; one `**` getter covers every depth | [Recursive paths](#recursive-paths-recursion) |
134
134
  | **Reactivity** | An ES Proxy tracks reads per address, caches per address, invalidates in dependency order and batches DOM writes on a microtask | [Updating state](#updating-state) · [Dependency tracking boundaries](#dependency-tracking-boundaries) |
135
- | **What makes a getter run** | Getters are lazy. Demand comes from a live binding, a `$watch` or a `$streams` `args` — and from nowhere else | [Demand roots](#demand-roots--what-makes-a-getter-run) |
135
+ | **What makes a getter run** | Getters are lazy. Demand comes from a live binding, a `$watch` or a `$stream` `args` — and from nowhere else | [Demand roots](#demand-roots--what-makes-a-getter-run) |
136
136
  | **Modularity** | `mount=` grafts a module onto the one tree; `state: path` mounts a subtree onto a component; the per-property form maps single keys, and a mounted component's getters are exported at its mount point | [Volumes](#mounting-additional-state-mount) · [Whole-object mount](#whole-object-mount-state-path) |
137
137
  | **Components** | Two mutually exclusive mechanisms: a JavaScript class with `bind-component`, or HTML-only DCC | [Choosing a mechanism](#choosing-a-component-mechanism) |
138
138
  | **Wiring to other elements** | The wc-bindable protocol, spread (`...: obj`), `#init=` / `#sync=` authority, property-to-attribute mirroring | [Binding authority](#binding-authority-init--sync) · [Spread](#spread-binding) · [Inputs](#inputs-and-attribute-mirror) |
139
139
  | **Tokens** | Command tokens call an element's methods from state; event tokens carry the element's events back | [Command token](#command-token-method-binding) · [Event token](#event-token-event-binding) |
140
- | **Time** | `$streams` folds an async source, `$watch` reacts headlessly, `$scan` owns an accumulation that outlives both | [Choosing a time mechanism](#choosing-a-time-mechanism) |
140
+ | **Time** | `$stream` folds an async source, `$watch` reacts headlessly, `$scan` owns an accumulation that outlives both | [Choosing a time mechanism](#choosing-a-time-mechanism) |
141
141
  | **Initialization and lifecycle** | Six ways to supply the state; `$connectedCallback` … `$stateReadyCallback`; `bootstrapState()` / `createState()` | [State initialization](#state-initialization) · [Lifecycle hooks](#lifecycle-hooks) · [API reference](#api-reference) |
142
142
  | **Diagnostics** | Unresolved paths, index arity, wildcard rank and getter cycles are reported; one failing binding stays confined, and neither values nor the DOM are rolled back | [Diagnostics](#diagnostics-and-failure-handling) |
143
143
  | **Delivery** | Zero runtime dependencies, no build step, ESM, one CDN `/auto` tag; no `unsafe-eval`, Trusted Types supported | [Installation](#installation) · [docs/csp.md](../../docs/csp.md) |
@@ -187,15 +187,15 @@ leaves features out, it can compose them instead:
187
187
  <script type="importmap">
188
188
  {
189
189
  "imports": {
190
- "@wcstack/state/core": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.2.0/dist/split/core.js",
191
- "@wcstack/state/features/temporal": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.2.0/dist/split/features/temporal.js",
192
- "@wcstack/state/features/scopes": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.2.0/dist/split/features/scopes.js"
190
+ "@wcstack/state/core": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.4.0/dist/split/core.js",
191
+ "@wcstack/state/features/temporal": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.4.0/dist/split/features/temporal.js",
192
+ "@wcstack/state/features/scopes": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.4.0/dist/split/features/scopes.js"
193
193
  }
194
194
  }
195
195
  </script>
196
196
  <script type="module">
197
197
  import { bootstrapState, installFeatures } from '@wcstack/state/core';
198
- import temporal from '@wcstack/state/features/temporal'; // $watch / $scan / $streams
198
+ import temporal from '@wcstack/state/features/temporal'; // $watch / $scan / $stream
199
199
  import scopes from '@wcstack/state/features/scopes'; // bind-component, mount=, DCC
200
200
 
201
201
  installFeatures([temporal, scopes]);
@@ -203,6 +203,12 @@ leaves features out, it can compose them instead:
203
203
  </script>
204
204
  ```
205
205
 
206
+ Call `installFeatures([...])` **before** `bootstrapState()`, as above: a feature can register a tag of its
207
+ own (`<wcs-ssr>`), and bootstrapping is what defines the tags. The reverse order is not an error — a
208
+ feature installed afterwards catches up and defines its tags against the registries already
209
+ bootstrapped — but keeping the declared order means every tag is defined before the first element
210
+ upgrades.
211
+
206
212
  Load the split form from the package's own files — jsDelivr's plain, version-pinned `/npm/` path as
207
213
  above (it does not read `exports`, so name the file under `dist/split/`), or a bundler — and **never
208
214
  through `esm.run`**. Its `+esm` endpoint re-bundles every entry on the server and inlines the shared
@@ -214,11 +220,11 @@ so the browser evaluates the engine once. Integrity for this form: [docs/sri.md
214
220
  | Entry | What it adds |
215
221
  |---|---|
216
222
  | `@wcstack/state/core` | The binding engine: `data-wcs`, `for` / `if`, path getters, filters, events, `$command` / `$on`, `bootstrapState`, `installFeatures` |
217
- | `@wcstack/state/features/temporal` | `$watch`, `$scan`, `$streams` |
223
+ | `@wcstack/state/features/temporal` | `$watch`, `$scan`, `$stream` |
218
224
  | `@wcstack/state/features/scopes` | `bind-component`, `mount=` volumes, overlay exports, DCC (`data-wc-definition`) |
219
225
  | `@wcstack/state/features/recursion` | `$recursion` and `**` paths |
220
226
  | `@wcstack/state/features/ssr` | `enable-ssr`: server rendering and hydration |
221
- | `@wcstack/state/features/formats` | The formatting filters (`uc`, `date`, `round`, `truncate`, …). The core answers only `not`, which `if` / `else` need |
227
+ | `@wcstack/state/features/formats` | The formatting filters (`upper`, `date`, `round`, `truncate`, …). The core answers only `not`, which `if` / `else` need |
222
228
  | `@wcstack/state/features/devtools` | The DevTools hook protocol source |
223
229
  | `@wcstack/state/features/diagnostics` | Development-time warnings: a bound / `$watch` / `$scan` path that does not resolve on the state is reported with a did-you-mean. Without it the page stays silent — thrown errors keep their full messages either way |
224
230
  | `@wcstack/state/define` | `defineState` and the types only — no runtime at all |
@@ -226,8 +232,11 @@ so the browser evaluates the engine once. Integrity for this form: [docs/sri.md
226
232
  A declaration whose feature is missing does not fail quietly: it throws
227
233
  `[wcs/feature-not-installed] … install it with installFeatures([...]) from "@wcstack/state/features/…"`
228
234
  when the state is defined, and a filter with no implementation throws `[wcs/filter-unknown]` when the
229
- bindings are planned. Installing is idempotent, and every entry shares one core chunk (a feature
230
- never carries a second copy of the engine).
235
+ bindings are planned. A volume (`<wcs-state mount=…>`) declaring `$watch` throws the same
236
+ `[wcs/feature-not-installed]` when it grafts, because a volume's state never passes through the
237
+ element's own declaration gate. Installing is idempotent, and every entry shares one core chunk —
238
+ a feature never carries a second copy of the engine, and never a copy of another feature either
239
+ (a call across that seam goes through a receptacle the owning feature fills on install).
231
240
 
232
241
  ## Basic Usage
233
242
 
@@ -334,7 +343,7 @@ There is **one state tree per root**. To split state across modules, mount a vol
334
343
  <div data-wcs="textContent: cart.total"></div>
335
344
  ```
336
345
 
337
- A volume may declare getters, `$watch`, `$listKeys`, `$updatedCallback`, and `$connectedCallback`/`$disconnectedCallback` — all relative to its mount path. `$errorCallback` is root-only (a binding failure is reported once, to the tree's owner). Load order does not matter (a volume connected before the root is grafted when the root registers). If the root `<wcs-state>` fails to initialize, the volumes already waiting for it settle with a report of their own instead of waiting forever. That report is the end of the line for those volumes: a volume reported as an orphan does not graft itself later, so connecting a corrected root afterwards does not bring it back. A volume that settles without grafting — orphaned, failed to load, or failed to graft — releases its mount slot, and so does a volume detached while it is still loading or waiting for its root. Such a volume takes the slot back when it is re-attached to the same root, or otherwise just before it grafts, and still grafts as before when the slot is free — even while detached; if another volume took the slot in the meantime, it reports that and does not graft. A synchronous throw from a volume's `$connectedCallback` is reported like an asynchronous one, and the volume counts as grafted. To recover without reloading the page, remove the broken root and the orphaned volumes and add new elements. A grafted volume keeps its slot even when detached, because its data stays in the tree. Mount paths must be static (`*`, `$`, `#`, `@` are rejected). Changing `mount` after the element has initialized is not supported: the change is ignored with a console warning — remove the element and add a new one with the desired path.
346
+ A volume may declare getters, `$watch`, `$listKeys`, `$renderedCallback`, and `$connectedCallback`/`$disconnectedCallback` — all relative to its mount path. `$errorCallback` is root-only (a binding failure is reported once, to the tree's owner). Load order does not matter (a volume connected before the root is grafted when the root registers). If the root `<wcs-state>` fails to initialize, the volumes already waiting for it settle with a report of their own instead of waiting forever. That report is the end of the line for those volumes: a volume reported as an orphan does not graft itself later, so connecting a corrected root afterwards does not bring it back. A volume that settles without grafting — orphaned, failed to load, or failed to graft — releases its mount slot, and so does a volume detached while it is still loading or waiting for its root. Such a volume takes the slot back when it is re-attached to the same root, or otherwise just before it grafts, and still grafts as before when the slot is free — even while detached; if another volume took the slot in the meantime, it reports that and does not graft. A synchronous throw from a volume's `$connectedCallback` is reported like an asynchronous one, and the volume counts as grafted. To recover without reloading the page, remove the broken root and the orphaned volumes and add new elements. A grafted volume keeps its slot even when detached, because its data stays in the tree. Mount paths must be static (`*`, `$`, `#`, `@` are rejected). Changing `mount` after the element has initialized is not supported: the change is ignored with a console warning — remove the element and add a new one with the desired path.
338
347
 
339
348
  **Injecting root paths into a volume (3.1).** When a volume's code needs a path outside its own subtree, write the injection on the volume element's `data-wcs`, in the same form as a component's partial mount:
340
349
 
@@ -342,7 +351,7 @@ A volume may declare getters, `$watch`, `$listKeys`, `$updatedCallback`, and `$c
342
351
  <wcs-state mount="cart" src="./cart.js" data-wcs="state.taxRate: settings.taxRate"></wcs-state>
343
352
  ```
344
353
 
345
- Inside `cart.js` — getters, methods, `$watch`, `$listKeys` and the lifecycle callbacks — `this.taxRate` reads and writes the root's `settings.taxRate`. The read records a dependency, so `get total()` re-evaluates when `settings.taxRate` changes. `$watch: { taxRate() {…} }` fires on changes to `settings.taxRate`, and `$updatedCallback` receives that update under the inner name `taxRate`. The injected name exists only inside the volume's code: the page keeps reading `settings.taxRate`, and the tree has no `cart.taxRate`.
354
+ Inside `cart.js` — getters, methods, `$watch`, `$listKeys` and the lifecycle callbacks — `this.taxRate` reads and writes the root's `settings.taxRate`. The read records a dependency, so `get total()` re-evaluates when `settings.taxRate` changes. `$watch: { taxRate() {…} }` fires on changes to `settings.taxRate`, and `$renderedCallback` receives that update under the inner name `taxRate`. The injected name exists only inside the volume's code: the page keeps reading `settings.taxRate`, and the tree has no `cart.taxRate`.
346
355
 
347
356
  - **One key at a time.** The left side is a single `state.<key>`. `state: …` cannot move the whole volume; change `mount` for that.
348
357
  - **The injection wins over the volume's own key.** A data key of the same name (`taxRate: 0`) is not grafted. An accessor or method of the same name would make `this.taxRate` ambiguous, so that volume reports an error and does not graft.
@@ -355,8 +364,8 @@ Inside `cart.js` — getters, methods, `$watch`, `$listKeys` and the lifecycle c
355
364
  |---|---|---|---|
356
365
  | Data keys, getters, setters, methods | yes | yes, relative to `p` | yes — own keys are private unless the host maps them; getters are exported |
357
366
  | `$connectedCallback` / `$disconnectedCallback` | yes | yes, relative to `p` | yes |
358
- | `$watch`, `$listKeys`, `$updatedCallback` | yes | yes, relative to `p` | not run — one `wcs/mount-dollar-declaration` warning |
359
- | `$streams`, `$scan`, `$recursion`, `**` getters | yes | rejected with an error before grafting | not run — warning |
367
+ | `$watch`, `$listKeys`, `$renderedCallback` | yes | yes, relative to `p` | not run — one `wcs/mount-dollar-declaration` warning |
368
+ | `$stream`, `$scan`, `$recursion`, `**` getters | yes | rejected with an error before grafting | not run — warning |
360
369
  | `$commandTokens`, `$eventTokens`, `$on` | yes | not run — warning | not run — warning |
361
370
  | `$errorCallback` | yes | not run — warning (silent before 3.0) | not run — warning (silent before 3.0) |
362
371
  | An initialization failure | reported once; `connectedCallbackPromise` rejects | settles without grafting and releases its slot; `connectedCallbackPromise` resolves (a missing `scopes` feature rejects it) | reported once; the component's `connectedCallbackPromise` rejects |
@@ -653,14 +662,16 @@ Runtime reads `customClass.wcBindable.properties + inputs` and expands each name
653
662
  <wcs-fetch data-wcs="...: usersFetch; status: alternateStatus"></wcs-fetch>
654
663
  ```
655
664
 
656
- **`undefined` is "no opinion"** — when an expanded state path resolves to `undefined` (e.g. the slot object doesn't initialize that input), the property write is **skipped** and the element keeps its own default. You only need to initialize the paths you actually use; `usersFetch: { value: null, loading: false }` is enough even though `<wcs-fetch>` also declares `method` / `manual` / `body`. To explicitly clear a value, assign `null` — `null` is always written. (This skip applies to every property binding that feeds an element input, not just spread; with `config.debug` each skipped write is logged via `console.debug`.) **Display surfaces are different (3.0):** `textContent` / `innerText` / `innerHTML`, mustache text, `attr.*` and `style.*` have no element default worth keeping, so `undefined` and `null` both mean "no value" there — the text becomes empty and the attribute or style is removed. Before 3.0 a `textContent:` binding skipped `undefined` too, which left the previous row's text in a reused list row, and an attribute got the string `"undefined"` / `"null"`.
665
+ **`undefined` is "no opinion"** — when an expanded state path resolves to `undefined` (e.g. the slot object doesn't initialize that input), the property write is **skipped** and the element keeps its own default. You only need to initialize the paths you actually use; `usersFetch: { value: null, loading: false }` is enough even though `<wcs-fetch>` also declares `method` / `manual` / `body`. To explicitly clear a value, assign `null` — `null` is always written. (This skip applies to every property binding that feeds an element input, not just spread; with `config.debug` each skipped write is logged via `console.debug`.) **Display surfaces are different (3.0):** `textContent` / `innerText` / `innerHTML`, mustache text, `attr.*`, `style.*` and `class.*` have no element default worth keeping, so `undefined` and `null` both mean "no value" there — the text becomes empty, the attribute or style is removed, and the class is taken off. Before 3.0 a `textContent:` binding skipped `undefined` too, which left the previous row's text in a reused list row, and an attribute got the string `"undefined"` / `"null"`. `class.*` is the narrow case: only `undefined` and `null` mean "no value"; **any other non-boolean still throws**, because a class binding is a boolean toggle and a truthy string is far more likely a mistake than an intent. Write `class.on: flag|truthy` when you mean "treat it as truthy".
666
+
667
+ **Filters keep the contract.** The emptiness test runs on the value *after* the filters, so a filter that builds its result with `String(value)` used to turn an absent value back into the characters `"undefined"` — `attr.title: x|trim` wrote `title="undefined"` where the unfiltered `attr.title: x` removes the attribute. The formatting family (`upper`, `lower`, `capitalize`, `trim`, `slice`, `substr`, `padStart`, `padEnd`, `repeat`, `reverse`, `truncate`, `unit`) now passes `undefined` / `null` straight through, so the apply side still empties the text and removes the attribute. Three groups deliberately do **not**: the filters for which an absent value *is* the input (`defaults`, `coalesce`, `nullIfEmpty`, `boolean`, `truthy`, `falsy`, `not`, `eq`, `ne`), the conversions whose whole job is to convert (`int`, `float`, `number`, `string` — `undefined|string` is still `"undefined"`), and the filters that check the value's type (the number, date and array families, e.g. `toFixed` / `date` / `join`). Those last ones throw, which is confined to the one binding and reported through `$errorCallback` — put a `coalesce` in front when the value can legitimately be absent (`price|coalesce(0)|toFixed(2)`).
657
668
 
658
669
  **Constraints**:
659
670
 
660
671
  - Filters on the spread target (`...: target|filter`) are rejected.
661
672
  - The right-hand path may contain `*` anywhere (e.g. `...: stores.*.fetch`).
662
673
  - The right-hand side is a plain tree path (`...: fetchX` or `...: stores.*.fetch`).
663
- - If the custom element class is not yet registered, expansion is deferred until `customElements.whenDefined(tag)` resolves — autoloader-style late registration is supported.
674
+ - 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.
664
675
  - Elements **without** a `wcBindable` declaration are rejected (write bindings explicitly). Spread requires the contract to know what to expand.
665
676
 
666
677
  **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.
@@ -727,7 +738,7 @@ Inside a `for` loop, paths starting with `.` are expanded relative to the loop's
727
738
  |---|---|---|
728
739
  | `.name` | `users.*.name` | Property of the current element |
729
740
  | `.` | `users.*` | The current element itself |
730
- | `.name\|uc` | `users.*.name\|uc` | Filters are preserved |
741
+ | `.name\|upper` | `users.*.name\|upper` | Filters are preserved |
731
742
 
732
743
  For primitive arrays, `.` refers to the element value directly:
733
744
 
@@ -1043,7 +1054,7 @@ Two-way binding works with path setters — editing the input calls the setter,
1043
1054
 
1044
1055
  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.
1045
1056
 
1046
- 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.
1057
+ 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`).
1047
1058
 
1048
1059
  ### Getters must be pure with respect to state
1049
1060
 
@@ -1060,9 +1071,9 @@ The rule: **read only through `this`, and don't write state or touch the DOM fro
1060
1071
 
1061
1072
  | API | Use it for |
1062
1073
  |---|---|
1063
- | `this.$trackDependency(path)` | Register an extra dependency so this getter is dirtied when that path changes |
1074
+ | `this.$dependOn(path)` | Register an extra dependency so this getter is dirtied when that path changes |
1064
1075
  | `this.$postUpdate(path)` | Announce that an untracked input changed, from outside the getter |
1065
- | `this.$untrackDependency(fn)` | Read a path *without* registering it as a dependency (the inverse) |
1076
+ | `this.$untracked(fn)` | Read a path *without* registering it as a dependency (the inverse) |
1066
1077
  | `this.$eq(path, key)` / `$eqPath(path, keyPath)` / `$eqIndex(path)` | A keyed subscription: "is `path` equal to this row's key?" without a pattern dependency (see [Keyed selection](#keyed-selection-eq--eqpath--eqindex)) |
1067
1078
 
1068
1079
  ```javascript
@@ -1086,9 +1097,9 @@ Three rules decide what the dependency graph sees. None of them matters until yo
1086
1097
  | **Reads inside a setter are not tracked.** A setter is an imperative assignment, not a derivation, so nothing it reads becomes a dependency of anything | A setter that reads `this.a` to decide what to write does not run again when `a` changes — only a getter re-runs |
1087
1098
  | **The same-value guard applies to primitives only.** A primitive write `Object.is`-equal to the current value is dropped before anything is enqueued; an object or array write always passes, even the same reference | Assigning the same string again fires nothing; assigning the same object again re-fires its bindings and `$watch` (`config.sameValueGuard`; a `semantics: "event"` property is exempt either way) |
1088
1099
 
1089
- The first rule is the one static analysis can catch: `wcs-validate` and the VS Code extension report `wcs/getter-untracked-read` when a getter reads `this.form.name` and the document writes `form.name` somewhere (a `value:` binding, a spread, `this["form.name"] = …`). A root that is only ever replaced wholesale — router params, a `$streams` fold — is left alone.
1100
+ The first rule is the one static analysis can catch: `wcs-validate` and the VS Code extension report `wcs/getter-untracked-read` when a getter reads `this.form.name` and the document writes `form.name` somewhere (a `value:` binding, a spread, `this["form.name"] = …`). A root that is only ever replaced wholesale — router params, a `$stream` fold — is left alone.
1090
1101
 
1091
- `$untrackDependency(fn)` applies the setter rule to a getter on purpose: reads inside `fn` are not tracked. `$trackDependency(path)` is the escape hatch for the first rule.
1102
+ `$untracked(fn)` applies the setter rule to a getter on purpose: reads inside `fn` are not tracked. `$dependOn(path)` is the escape hatch for the first rule.
1092
1103
 
1093
1104
  ### Keyed selection (`$eq` / `$eqPath` / `$eqIndex`)
1094
1105
 
@@ -1120,7 +1131,9 @@ What reaches the rows:
1120
1131
  - **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.
1121
1132
  - **A write to an object above `path`** — `this.sel = { id: 2 }` for `$eq("sel.id", …)`: the same two rows, keyed by the value `path` had under the old object and has under the new one.
1122
1133
  - **A `path` that is a getter, or sits under one** (`$eq("current.id", …)` with `get current()`): its value changes without a write to it, so the calls fall back to an ordinary tracked read. The selection stays correct, but a change re-evaluates every row, as without the keyed form. Point `path` at the written state (`selectedId`) to keep the two-row cost. The State pane of `@wcstack/devtools` counts the subscriptions per path under **Keyed selection** and marks a path that fell back this way with a `tracked` badge (3.0).
1134
+ - **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.
1123
1135
  - **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.
1136
+ - **Inside a scope, `path` is relative** — like every other `$` API that takes a path. In a mounted component (`bind-component`) and in a volume (`<wcs-state mount="cart">`), `$eq("selectedId", …)`, `$eqPath`'s two paths, `$eqIndex` and `$dependOn` all resolve against that scope (`cart.selectedId`), not against the root. `$untracked` takes a callback, so nothing is translated there.
1124
1137
 
1125
1138
  ### Loop Index Variables (`$1`, `$2`, ...)
1126
1139
 
@@ -1152,7 +1165,7 @@ You can also display the loop index directly in templates:
1152
1165
 
1153
1166
  ```html
1154
1167
  <template data-wcs="for: items">
1155
- <td>{{ $1|inc(1) }}</td> <!-- 1-based row number -->
1168
+ <td>{{ $1|add(1) }}</td> <!-- 1-based row number -->
1156
1169
  </template>
1157
1170
  ```
1158
1171
 
@@ -1268,6 +1281,12 @@ export default {
1268
1281
 
1269
1282
  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.
1270
1283
 
1284
+ 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.
1285
+
1286
+ 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.
1287
+
1288
+ **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.
1289
+
1271
1290
  ## Recursive Paths (`$recursion`)
1272
1291
 
1273
1292
  A path burns its depth into the string. `nodes.*.children.*.total` has exactly two wildcard levels, and nothing about it stretches to three when the tree grows a level — but a tree's depth belongs to the data, not to the code. `$recursion` closes that gap: declare where the shape repeats, then write `**` for "however deep this is".
@@ -1339,7 +1358,7 @@ k=2 nodes.*.children.*.children.*
1339
1358
  | `$getAll(path, [])`, **explicit** | **Union of every depth** — depth-first, pre-order, ascending index |
1340
1359
  | `$getAll(path, [i, …])` | Rejected: a prefix cannot say which depth it applies to (`wcs/recursion-getall-form`) |
1341
1360
  | `$setAll(path, [], value)` | Broadcast to every depth, in that same order |
1342
- | `$resolve`, `$postUpdate`, `$trackDependency`, `$watch` keys, `$listKeys` keys, `data-wcs` in markup, direct assignment | Rejected (`wcs/recursion-unsupported`) |
1361
+ | `$resolve`, `$postUpdate`, `$dependOn`, `$watch` keys, `$listKeys` keys, `data-wcs` in markup, direct assignment | Rejected (`wcs/recursion-unsupported`) |
1343
1362
 
1344
1363
  The bound forms need a depth to bind to, so they only resolve **inside** a recursive getter — or inside an ordinary row getter under the anchor, or an event handler bound to such a row — each of those carries a real `ListIndex` to read the depth from. Read `this["nodes.**.value"]` from the top level and you get `wcs/recursion-context`, not a silent guess at which node you meant. The depth is read from the innermost evaluation frame only, the same frame the row index comes from: a plain getter that a recursive getter calls (`get "nodes.**.x"() { return this.helper }` with `get helper() { return this["nodes.**.value"] }`) has no row of its own and gets `wcs/recursion-context` too — read `**` in the recursive getter and pass the value on. The union form needs no depth, so it can be read from anywhere: a top-level getter, a plain row getter, a method.
1345
1364
 
@@ -1408,8 +1427,8 @@ Every other form is refused *before* the walk writes anything, so a rejected cal
1408
1427
  | omitted indexes | The write API takes no context, so there is no depth to bind to — pass `[]` |
1409
1428
  | a mapper function | `(current, ...indexes)` has a different arity at every depth |
1410
1429
  | `{ spread: true }` | Handing a flat array to a tree needs the author to know the walk order |
1411
- | `nodes.**`, `nodes.**.children`, `nodes.**.children.*`, `nodes.**.children.length` — and, for a multi-segment repeat such as `branch.children.*`, the `nodes.**.branch` on the way to the list. Index spellings fold to the same forms: `nodes.**.children.0` is a child node, `nodes.**.children.0.total` is the getter | Writing the structure itself (assigning `length` truncates the list) invalidates the child addresses this very write already resolved (`wcs/recursion-structural-write`) |
1412
- | `nodes.**.total`, or a path inside its value | A recursive getter has no setter — write what it derives from (`wcs/recursion-readonly`) |
1430
+ | `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`) |
1431
+ | `nodes.**.total`, or a path inside its value — index spellings fold to the same form too, so `nodes.**.children.0.total` is the getter | A recursive getter has no setter — write what it derives from (`wcs/recursion-readonly`) |
1413
1432
 
1414
1433
  The read-only rule does not depend on spelling `**`. A recursive getter's concrete expansions — `nodes.*.total`, `nodes.*.children.*.total`, … — are refused at the write entry as well, whether the write is a fixed-arity `$setAll`, a `$resolve(path, indexes, value)` or a direct assignment, and whether or not that depth has been materialized yet. Before this check, an unmaterialized expansion looked like a plain missing key and the write landed on the node object, pinning the assigned value as the getter's cached result.
1415
1434
 
@@ -1417,7 +1436,9 @@ The read-only rule does not depend on spelling `**`. A recursive getter's concre
1417
1436
 
1418
1437
  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.
1419
1438
 
1420
- 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 on screen, 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. 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.
1439
+ 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.
1440
+
1441
+ 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)).
1421
1442
 
1422
1443
  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.
1423
1444
 
@@ -1465,7 +1486,7 @@ Each of these is a diagnostic, never a silent reinterpretation:
1465
1486
  - Recursive setters, a `**` getter whose suffix names the structure (`get "nodes.**.children"()`), a concrete getter with the same name as a `**` getter's expansion, and writing through `**` by assignment (`this["nodes.**.x"] = v`, `++` included)
1466
1487
  - In a recursive `$setAll`: a mapper, `{ spread: true }`, omitted indexes, a non-empty prefix, or a non-array `indexes`. The write API has no evaluation context to bind a depth to, so `[]` is mandatory
1467
1488
  - In a recursive `$getAll`: a non-empty prefix, or a non-array `indexes`. **Omitting the indexes is valid** — inside a recursive getter it is the bound form, and it reads the depth being evaluated
1468
- - `**` in `data-wcs`, in `$watch` or `$listKeys` keys, or in `$resolve` / `$postUpdate` / `$trackDependency`
1489
+ - `**` in `data-wcs`, in `$watch` or `$listKeys` keys, or in `$resolve` / `$postUpdate` / `$dependOn`
1469
1490
  - `$recursion` and `**` getters in a volume (`mount=`) or a mounted component (`bind-component`) — declare them on the root state
1470
1491
  - A recursive `<template>`, a `$depth` variable, and a public `maxDepth` option — none of the three exist
1471
1492
 
@@ -1511,7 +1532,7 @@ export default {
1511
1532
  |---|---|---|
1512
1533
  | `eq(value)` | Equal | `count\|eq(0)` → `true/false` |
1513
1534
  | `ne(value)` | Not equal | `count\|ne(0)` |
1514
- | `not` | Boolean NOT | `isActive\|not` |
1535
+ | `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` |
1515
1536
  | `lt(n)` | Less than | `count\|lt(10)` |
1516
1537
  | `le(n)` | Less than or equal | `count\|le(10)` |
1517
1538
  | `gt(n)` | Greater than | `count\|gt(0)` |
@@ -1550,12 +1571,12 @@ export default {
1550
1571
  | `capitalize` (`cap`) | Capitalize | `name\|capitalize` |
1551
1572
  | `trim` | Trim whitespace | `text\|trim` |
1552
1573
  | `slice(n)` | Slice string | `text\|slice(5)` |
1553
- | `substr(start, length)` | Substring | `text\|substr(0,10)` |
1574
+ | `substr(start, length)` | Substring (both arguments required) | `text\|substr(0,10)` |
1554
1575
  | `padStart(n, char?)` (`pad`) | Pad the start (default `0`) | `id\|padStart(5,0)` → `"00001"` |
1555
1576
  | `padEnd(n, char?)` | Pad the end (default a space; 3.2) | `code\|padEnd(8)` |
1556
1577
  | `repeat(n)` (`rep`) | Repeat | `text\|repeat(3)` |
1557
1578
  | `reverse` (`rev`) | Reverse | `text\|reverse` |
1558
- | `truncate(n, suffix?)` | Shorten and append an ellipsis | `title\|truncate(20)` |
1579
+ | `truncate(n, suffix?)` | Shorten and append an ellipsis (default `…`, one U+2026 character) | `title\|truncate(20)` |
1559
1580
  | `join(sep?)` | Join an array (default `", "`) | `tags\|join` / `tags\|join(/)` |
1560
1581
 
1561
1582
  ### Type Conversion
@@ -1573,11 +1594,13 @@ export default {
1573
1594
 
1574
1595
  | Filter | Description | Example |
1575
1596
  |---|---|---|
1576
- | `date(loc?)` | Date format | `timestamp\|date` / `timestamp\|date(ja-JP)` |
1577
- | `time(loc?)` | Time format | `timestamp\|time` |
1578
- | `datetime(loc?)` | Date + Time | `timestamp\|datetime(en-US)` |
1579
- | `ymd(sep?)` | YYYY-MM-DD | `timestamp\|ymd` / `timestamp\|ymd(/)` |
1580
- | `hms(sep?)` | HH:MM:SS | `timestamp\|hms` / `timestamp\|hms(-)` |
1597
+ | `date(loc?)` | Date format | `createdAt\|date` / `createdAt\|date(ja-JP)` |
1598
+ | `time(loc?)` | Time format | `createdAt\|time` |
1599
+ | `datetime(loc?)` | Date + Time | `createdAt\|datetime(en-US)` |
1600
+ | `ymd(sep?)` | YYYY-MM-DD (default `-`) | `createdAt\|ymd` / `createdAt\|ymd(/)` |
1601
+ | `hms(sep?)` | HH:MM:SS (default `:`) | `createdAt\|hms` / `createdAt\|hms(-)` |
1602
+
1603
+ **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).
1581
1604
 
1582
1605
  ### Boolean / Default
1583
1606
 
@@ -1598,7 +1621,15 @@ Filters can be chained with `|`:
1598
1621
 
1599
1622
  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.
1600
1623
 
1601
- **Argument literals are typed (3.0).** An unquoted `true`, `false`, `null` or number is that value; a quoted argument is a string. The comparison filters use it for booleans and `null`: `done|eq(true)` matches `true` (before 3.0 it compared with the string `"true"` and never matched), `eq('true')` does not, and `eq(null)` matches `null`. Numbers and strings compare as before — a numeric value against the number, a string value against the text — so a form value `"1"` still matches `eq(1)`. `defaults(v)` returns the typed value: `defaults(0)` gives `0`, `defaults('0')` gives `"0"`, `defaults(null)` gives `null`.
1624
+ **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`.
1625
+
1626
+ **Quoting rules.** Quotes (`'` or `"`) mark a literal, and the argument grammar is deliberately small:
1627
+
1628
+ - Inside quotes, `,` `;` `|` and `:` are ordinary characters, so `join(', ')`, `join(': ')` and `join(';')` all parse.
1629
+ - Whitespace is trimmed **outside** the quotes only: `padStart(5, ' ')` pads with a space, `toFixed( 2 )` is `toFixed(2)`.
1630
+ - **There is no escape character.** A quote of the same kind cannot appear inside its own literal — write `"it's"` rather than `'it\'s'`. (`'it\'s'` is an unterminated quote and throws `[wcs/binding-syntax]`.)
1631
+ - Adjacent runs are concatenated into one argument: `'a' 'b'` is the single string `a b`, and `1'2'` is the string `12` (any quote in the argument makes the whole argument a string).
1632
+ - An unterminated quote is rejected; a trailing empty argument is dropped (`filter()` takes no arguments), while a leading or middle empty argument keeps its position (`defaults(,)` passes one empty string).
1602
1633
 
1603
1634
  ## Web Component Binding
1604
1635
 
@@ -1713,6 +1744,7 @@ customElements.define("user-card", UserCard);
1713
1744
  - A partial mount can sit next to it: `state: user; state.theme: theme` mounts `theme` as a second entry point (longest prefix wins, so `theme.mode` inside the component reads the tree's `theme.mode`).
1714
1745
  - In a loop, mount **the row itself**: `<template data-wcs="for: users"><user-row data-wcs="state: ."></user-row></template>`. Inside the row component `name` is `users.*.name`, and its own `for: tags` runs over `users.*.tags.*`.
1715
1746
  - **Own keys are private** (rule R1 in [docs/state-mount-design.md](../../docs/state-mount-design.md) §4-3): a data key the component declares itself (`state = { mode: "view" }`) belongs to that element and is never written to the tree. If it hides a key that exists at the mount point (`state = { name: "" }` mounted over `user.name`), the runtime warns once (`wcs/mount-own-key-shadow`) — remove the default to read the tree, or rename it to keep it private.
1747
+ - A method taken from `element.state` (`card.state.toggle()`) runs as it does from an event binding (`onclick: toggle`): writes to own keys and to tree keys re-render, a row mount's call lands on its own row, and a synchronous method returns its value while an async one returns its Promise.
1716
1748
  - 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.
1717
1749
 
1718
1750
  > The per-property form (`state.message: user.name`) keeps working — it is a partial mount on
@@ -1720,7 +1752,11 @@ customElements.define("user-card", UserCard);
1720
1752
  > component that declares a default for a mapped key (`state = { message: "" }` together with
1721
1753
  > `state.message: ...`) reads the host value; the default is simply not used. (In 2.x R1 made
1722
1754
  > that own key private and it hid the host value, with a `wcs/mount-own-key-shadow` warning.)
1723
- > R1 still keeps every *unmapped* own key private.
1755
+ > R1 still keeps every *unmapped* own key private. This is about a **one-segment** entry: a deeper
1756
+ > entry (`state.a.b: outer.b`) does not touch the privacy of `a`, so if the component declares `a`
1757
+ > itself, `a` and everything under it stay private and the entry never reaches it. The same holds
1758
+ > for an entry whose name is a getter, a setter or a method of the component — the component's own
1759
+ > surface wins. Each of those shapes is reported once as `wcs/mount-own-key-shadow`; rename one side.
1724
1760
  >
1725
1761
  > **`#ro` on a mount is honoured (3.0):** `state#ro: user` or `state.title#ro: doc.title`
1726
1762
  > lets the component read the entry but not write it — `element.state.title = …`, `this.title = …`
@@ -1787,7 +1823,7 @@ customElements.define("my-component", MyComponent);
1787
1823
  - `<wcs-state>` with `bind-component` must be a **direct child** of the component element (top-level)
1788
1824
  - The parent element must be a **custom element** (tag name containing a hyphen)
1789
1825
  - Light DOM components must be wired from the host (the plain, unwired form was removed in v2)
1790
- - A **mounted** scope does not execute declaration surfaces: `$watch`, `$streams` and `$scan` are ignored there with a one-time warning, and `$recursion` / `**` getters are rejected. Declare them on the root state — a volume (`<wcs-state mount>`) can host `$watch`, while `$scan` and `$recursion` are root-only. An unwired Shadow DOM child owns an independent tree and can declare all of them
1826
+ - 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
1791
1827
 
1792
1828
  ### Loop with Components
1793
1829
 
@@ -1802,6 +1838,8 @@ customElements.define("my-component", MyComponent);
1802
1838
  </template>
1803
1839
  ```
1804
1840
 
1841
+ 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.
1842
+
1805
1843
  ### Rendering a List Inside the Component
1806
1844
 
1807
1845
  An array can be bound into a component and iterated with `for:` **inside** it. The outer state
@@ -1844,7 +1882,7 @@ that only passes the array through — running no `for:` of its own — still le
1844
1882
  from the owning scope reach the rows at the bottom.
1845
1883
 
1846
1884
  A component's author never has to know how deeply it is placed. `$1`, event-handler indexes,
1847
- `$updatedCallback` and `$getAll` all report positions **within the component's own scope**.
1885
+ `$renderedCallback` and `$getAll` all report positions **within the component's own scope**.
1848
1886
 
1849
1887
  ## Command Token (Method Binding)
1850
1888
 
@@ -1958,9 +1996,12 @@ interface CommandToken {
1958
1996
 
1959
1997
  `emit` returns an array of return values from each subscriber (in subscribe order). For `Promise`-returning methods, wrap with `Promise.all(token.emit(...))` to await all of them.
1960
1998
 
1999
+ **One subscriber throwing does not stop the others.** A token is a fan-out point and the subscribers do not know about each other, so `emit` calls every one of them: a subscriber that throws is reported with `console.error` (naming the token) and leaves `undefined` at its position in the result array, and the remaining subscribers still receive the call. The exception does **not** reach the emitter — the same rule as a state event handler, because the firing path does not wait for the handler and a DOM-event origin has no caller to throw back to. If a caller needs to know, have the subscriber return a value (or a rejected `Promise`) rather than throw. A `Promise`-returning subscriber that rejects is not caught here at all; that is what `Promise.all(token.emit(...))` is for.
2000
+
1961
2001
  ### Subscription Lifecycle
1962
2002
 
1963
- - The subscriber holds the element via `WeakRef`, so a removed element can still be garbage collected even while it remains in the token's subscriber set
2003
+ - The subscriber holds the element via `WeakRef`, so nothing in the token's subscriber set keeps the element alive **by itself**
2004
+ - **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
1964
2005
  - On `emit`, if the WeakRef has been collected or the element is no longer connected (`isConnected === false`), the subscription is purged automatically (lazy purge)
1965
2006
  - 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`
1966
2007
 
@@ -1986,7 +2027,7 @@ A command token does not have to be emitted from state code. A DOM event binding
1986
2027
 
1987
2028
  This is pure wiring: the event endpoint is connected to a command-token endpoint, with no logic in between. The `emit` arguments are passed through exactly like a handler call — the DOM `Event` first, then any enclosing list indexes — so subscribers receive `(event, ...listIndexes)`. Inside a subscriber, pull what you need from the event (`event.target.value`, `event.detail`, …).
1988
2029
 
1989
- - The right-hand side must be `$command.<name>` with `<name>` declared in `$commandTokens`. A path that does not resolve to a `CommandToken` (e.g. a typo) throws at event time.
2030
+ - The right-hand side must be `$command.<name>` with `<name>` declared in `$commandTokens`. A path that does not resolve to a `CommandToken` (e.g. a typo) fails at event time. A DOM event cannot be thrown back at, so it is reported once per event with `console.error` rather than propagated (the same landing as a state handler that rejects).
1990
2031
  - Modifiers work unchanged: `onclick#prevent: $command.someToken` calls `preventDefault()` before emitting (`#stop` likewise).
1991
2032
  - This emits the same token the state emits, so element subscribers wired with `command.<method>: $command.someToken` receive it regardless of who pulled the trigger.
1992
2033
 
@@ -2148,22 +2189,22 @@ The next four sections answer four different questions, and the usual mistake is
2148
2189
  | Declaration | What you declare | Owns a value | Fires | Typical use |
2149
2190
  |---|---|---|---|---|
2150
2191
  | [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 |
2151
- | [`$streams`](#streams-streams) | An async producer, and the value folded **within one run** | Yes — the runtime owns the output | Per chunk; restarts, back to `initial`, when `args` change | Feeds, sockets, continuous observation |
2192
+ | [`$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 |
2152
2193
  | [`$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" |
2153
2194
  | [`$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 |
2154
2195
 
2155
2196
  Two rules cut most of the confusion:
2156
2197
 
2157
- - **`$updatedCallback` is not on this list.** It reports the bindings that were applied, so anything hung on it silently depends on what is rendered. See [Demand roots](#demand-roots--what-makes-a-getter-run).
2158
- - **A `$streams` fold resets on every restart; a `$scan` does not.** When the value has to survive the restart, or has to count events rather than states, it belongs in `$scan`.
2198
+ - **`$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).
2199
+ - **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`.
2159
2200
 
2160
- ## Streams (`$streams`)
2201
+ ## Streams (`$stream`)
2161
2202
 
2162
2203
  > **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`.
2163
2204
 
2164
- Command tokens and event tokens carry discrete interactions. **`$streams`** covers the remaining shape: a continuous flow. Declare an async producer (async iterable / async generator / `ReadableStream`) and the framework **folds it into a single reactive property** — each chunk goes through normal path assignment, so bindings, path getters, and `$updatedCallback` react exactly as if you had assigned the value yourself. When a state path read by the `args` function changes, the running producer is aborted and the source is restarted with the new arguments (switchMap-style dependency-driven restart). Streams start eagerly after `$connectedCallback` completes and are aborted when the element disconnects.
2205
+ 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.
2165
2206
 
2166
- `$updatedCallback` remains binding-driven: a stream declaration alone is not a headless subscription. Its path appears in the callback only when a live DOM binding for that value/status/error is actually applied. To react to a stream's value without rendering it, declare [`$watch`](#watch-watch) on that path; see the [stream reference](docs/streams.md) for the observation contract.
2207
+ `$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.
2167
2208
 
2168
2209
  ```html
2169
2210
  <wcs-state>
@@ -2171,7 +2212,7 @@ Command tokens and event tokens carry discrete interactions. **`$streams`** cove
2171
2212
  export default {
2172
2213
  prompt: "",
2173
2214
 
2174
- $streams: {
2215
+ $stream: {
2175
2216
  // Full form: accumulate an LLM token stream
2176
2217
  tokens: {
2177
2218
  args: (state) => state.prompt, // dependencies are captured here, and only here
@@ -2210,7 +2251,7 @@ On error the property keeps its last folded value and the error lands in `$strea
2210
2251
  **Bridging an event API** — most real sources are callback-shaped (`EventSource`, `WebSocket`, DOM events), not async iterables. Wrap them in a standard `ReadableStream`: enqueue in `start`, release the resource in `cancel`. You never touch the `AbortSignal` — on restart/dispose the runtime cancels its reader, which force-unwinds a parked read and runs your `cancel()`:
2211
2252
 
2212
2253
  ```js
2213
- $streams: {
2254
+ $stream: {
2214
2255
  metrics: {
2215
2256
  args: (state) => ({ host: state.host }),
2216
2257
  source: ({ host }) => {
@@ -2236,6 +2277,7 @@ Key rules:
2236
2277
  - **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.
2237
2278
  - **`args` is synchronous** — returning a Promise is an error, and wildcard reads inside `args` are rejected.
2238
2279
  - **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.
2280
+ - **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`.)
2239
2281
  - **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.
2240
2282
 
2241
2283
  See [docs/streams.md](docs/streams.md) for the full contract — lifecycle and ownership, restart semantics, flush granularity, and the out-of-scope list.
@@ -2250,9 +2292,9 @@ There are exactly **three** demand roots:
2250
2292
  |---|---|---|
2251
2293
  | **A live DOM binding** | `data-wcs` / mustache / comment bindings | **Yes** — remove the element and the demand goes with it |
2252
2294
  | **A `$watch` declaration** | the state | No (headless) |
2253
- | **A `$streams` `args` function** | the state | No (evaluated on start and on every restart) |
2295
+ | **A `$stream` `args` function** | the state | No (evaluated on start and on every restart) |
2254
2296
 
2255
- **`$updatedCallback` is not a root.** It reports what the bindings did; it does not create demand.
2297
+ **`$renderedCallback` is not a root.** It reports what the bindings did; it does not create demand.
2256
2298
 
2257
2299
  ### Rendering can change program semantics
2258
2300
 
@@ -2264,27 +2306,27 @@ Because the first root lives in the DOM, **an element you think of as display-on
2264
2306
  ```
2265
2307
 
2266
2308
  ```javascript
2267
- // $updatedCallback is binding-driven — delete that <b> and the path stops
2309
+ // $renderedCallback is binding-driven — delete that <b> and the path stops
2268
2310
  // appearing in `paths`, so the feed silently stops committing.
2269
- $updatedCallback(paths) {
2311
+ $renderedCallback(paths) {
2270
2312
  if (!paths.includes("$streamStatus.pageResult")) return;
2271
2313
  this.items = this.items.concat(this.pageResult.items);
2272
2314
  }
2273
2315
  ```
2274
2316
 
2275
- **The rule:** logic that must not depend on what is rendered belongs on a `$watch`, a `$scan`, or a `$streams` `args`. Keep `$updatedCallback` for "follow what was drawn".
2317
+ **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".
2276
2318
 
2277
- That example now accumulates its feed with `$scan` (and re-arms the sentinel from a `$watch`), and the `<b>` is display-only again. This shape — `$updatedCallback` testing a path that is not bound anywhere — is detected statically as **`wcs/updated-callback-unbound`**.
2319
+ That example now accumulates its feed with `$scan` (and re-arms the sentinel from a `$watch`), and the `<b>` is display-only again. This shape — `$renderedCallback` testing a path that is not bound anywhere — is detected statically as **`wcs/updated-callback-unbound`**.
2278
2320
 
2279
2321
  ### The limitation that remains
2280
2322
 
2281
- Demand still comes from three separate places. **To know whether a getter is evaluated you have to inspect all three — every binding on the page, every `$watch`, and every `$streams` `args` — and reading the getter's definition will not tell you.** The linter and the DevTools wiring-coverage view exist to make a machine do that cross-check.
2323
+ 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.
2282
2324
 
2283
2325
  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.
2284
2326
 
2285
2327
  ## Watch (`$watch`)
2286
2328
 
2287
- `$updatedCallback` is **binding-driven**: it reports the paths whose live DOM bindings were actually applied in that update, so a value you never render is invisible to it. **`$watch`** is the headless counterpart — it fires on state changes whether or not anything on the page is bound to the path. (One exception, spelled out below: a *wildcard* row path needs `$listKeys` to work headlessly.)
2329
+ `$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.)
2288
2330
 
2289
2331
  ```html
2290
2332
  <wcs-state>
@@ -2329,11 +2371,11 @@ Firing order is defined in three layers, and only the middle one is yours to ste
2329
2371
 
2330
2372
  | Layer | Order | Your control |
2331
2373
  |---|---|---|
2332
- | Mechanisms | `$updatedCallback` → `$scan` → `$watch` → `$streams` restart | fixed |
2374
+ | Mechanisms | `$renderedCallback` → `$scan` → `$watch` → `$stream` restart | fixed |
2333
2375
  | Between handlers | declaration order in `$watch` | **reorder the declarations** |
2334
2376
  | Between rows of one path | ascending `indexes` | fixed |
2335
2377
 
2336
- **The one thing that moves the mechanism layer** is a `<wcs-view-transition>` that accepts the `state` participant. Binding application — and with it `$updatedCallback` — then lands on a frame, while `$scan`, `$watch` and the `$streams` restart stay on the microtask the drain was queued on, because they consume state addresses and not the DOM. For as long as the tag is present the order is `$scan` → `$watch` → `$streams` restart → `$updatedCallback`. Nothing else on the page reorders this layer; see [docs/timing-and-firing-contract.md](https://github.com/wcstack/wcstack/blob/main/docs/timing-and-firing-contract.md) §4.3.
2378
+ **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.
2337
2379
 
2338
2380
  Key rules:
2339
2381
 
@@ -2341,15 +2383,15 @@ Key rules:
2341
2383
  - **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.
2342
2384
  - **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.
2343
2385
  - **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.
2344
- - **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.
2345
- - **Handler exceptions are isolated** — a throw is reported to the console and the remaining watches (and stream restarts) still run. This differs from `$connectedCallback` / `$updatedCallback`, which fail loudly.
2346
- - **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.
2347
- - **Not run on a mounted `bind-component` scope** — mounted components do not execute declaration surfaces: the `$watch` declaration is ignored with a one-time console warning that points to the root state (or a volume — `<wcs-state mount>` hosts `$watch` / `$listKeys` / `$updatedCallback`). This applies to `$streams` too. A plain (unwired Shadow) child owns an independent tree and can declare it.
2386
+ - **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.
2387
+ - **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.
2388
+ - **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.
2389
+ - **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.
2348
2390
  - **SSR does not run watches** — handler side effects would otherwise execute on both server and client.
2349
2391
 
2350
2392
  ## Scan (`$scan`)
2351
2393
 
2352
- `$streams` folds *within* one run — every restart resets the value to `initial` — and `$watch` owns no value. **`$scan`** declares the value that has to outlive both: an accumulation over time, with an owner, a firing unit and a reset condition.
2394
+ `$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.
2353
2395
 
2354
2396
  ```html
2355
2397
  <wcs-state>
@@ -2358,7 +2400,7 @@ Key rules:
2358
2400
  page: 1,
2359
2401
  host: "a",
2360
2402
  $eventTokens: ["message"],
2361
- $streams: {
2403
+ $stream: {
2362
2404
  pageResult: { args: (s) => s.page, source: loadPage },
2363
2405
  },
2364
2406
  $scan: {
@@ -2394,7 +2436,7 @@ Key rules:
2394
2436
  | `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. |
2395
2437
  | `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. |
2396
2438
 
2397
- **The runtime owns the output**, like a `$streams` value. It is materialized from `initial` when the state does not already have that property (plain data is copied, so writing a child path in the plain part of the output never changes the declared `initial`; class instances, frozen values and other non-plain values stay shared with it), and you bind it like any other path. It survives stream restarts, disconnect and reconnect, and a re-set of the same object; a re-set with a new declaration rebuilds the scan. An output name that collides with a getter, a setter, a method or a `$streams` entry raises.
2439
+ **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.
2398
2440
 
2399
2441
  How the two sources fire:
2400
2442
 
@@ -2409,8 +2451,8 @@ Key rules:
2409
2451
  - **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`).
2410
2452
  - **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.
2411
2453
  - **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.
2412
- - **Receive element events through `on`.** A `from` path sees every write to that path, including a bound element's initial sync and a whole-parent write (which arrives with `prev === undefined`). `prev` follows `$watch`'s ledger, so it is also `undefined` for a write made inside the `$scan` / `$watch` listener — by a `$watch` handler, or by another scan whose output is the `from`. The ledger is cleared at the end of that listener, so the `$streams` restart that runs after it in the same drain keeps `prev`.
2413
- - **Keep folds bounded.** An infinite source must fold into a bounded value (the last N, a count), exactly as with `$streams`.
2454
+ - **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`.
2455
+ - **Keep folds bounded.** An infinite source must fold into a bounded value (the last N, a count), exactly as with `$stream`.
2414
2456
  - **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.
2415
2457
  - **`$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`.
2416
2458
  - **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.
@@ -2552,7 +2594,7 @@ Both declarations are validated when the component is defined, with the same str
2552
2594
  - an entry that is not a non-empty string
2553
2595
  - an entry starting with `$` (internal properties are never exposed on the component prototype)
2554
2596
  - a duplicated entry — this one used to fail silently: a duplicate name makes the whole `wcBindable` declaration unreadable, so the element would quietly stop being two-way bindable
2555
- - an entry that does not exist on the state (own properties and the prototype chain are both searched; `$streams` names count as existing since their value properties are materialized per instance)
2597
+ - an entry that does not exist on the state (own properties and the prototype chain are both searched; `$stream` names count as existing since their value properties are materialized per instance)
2556
2598
  - a method listed in `$bindables`, or a value property listed in `$commands`
2557
2599
 
2558
2600
  ### Driving a DCC Method
@@ -2612,7 +2654,7 @@ Properties prefixed with `$` are internal and not exposed on the component proto
2612
2654
  | `$commands` | Declares invocable methods |
2613
2655
  | `$connectedCallback` | Lifecycle hook (runs on each instance) |
2614
2656
  | `$disconnectedCallback` | Cleanup hook |
2615
- | `$updatedCallback` | Called after state mutations |
2657
+ | `$renderedCallback` | Called after state mutations |
2616
2658
 
2617
2659
  ## SVG Support
2618
2660
 
@@ -2628,7 +2670,7 @@ All bindings work inside `<svg>` elements. Use `attr.*` for SVG attributes:
2628
2670
 
2629
2671
  ## Lifecycle Hooks
2630
2672
 
2631
- State objects can define `$connectedCallback`, `$disconnectedCallback`, `$updatedCallback`, and `$errorCallback` for initialization, cleanup, update, and binding-failure handling.
2673
+ State objects can define `$connectedCallback`, `$disconnectedCallback`, `$renderedCallback`, and `$errorCallback` for initialization, cleanup, update, and binding-failure handling.
2632
2674
 
2633
2675
  > **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`.
2634
2676
 
@@ -2659,15 +2701,15 @@ State objects can define `$connectedCallback`, `$disconnectedCallback`, `$update
2659
2701
  |---|---|---|
2660
2702
  | `$connectedCallback` | After state initialization on first connect; on every reconnect thereafter | Yes (awaited) |
2661
2703
  | `$disconnectedCallback` | When the element is removed from the DOM | No (sync only) |
2662
- | `$updatedCallback(paths, indexesListByPath)` | After updates are applied to live bindings | Yes (not awaited) |
2663
- | `$errorCallback(error, info)` | After a drain in which a binding failed to apply — once per failed binding, after `$updatedCallback` | Yes (not awaited) |
2704
+ | `$renderedCallback(paths, indexesListByPath)` | After updates are applied to live bindings | Yes (not awaited) |
2705
+ | `$errorCallback(error, info)` | After a drain in which a binding failed to apply — once per failed binding, after `$renderedCallback` | Yes (not awaited) |
2664
2706
 
2665
2707
  All hooks except `$disconnectedCallback` support `async` — you can use `async/await` in any of them. Since the reactive proxy detects every property assignment as a change, standard `async/await` with direct property updates is sufficient for asynchronous operations — loading flags, fetched data, and error messages are all just property assignments, without requiring additional abstractions for async state management.
2666
2708
 
2667
2709
  - `this` inside hooks is the state proxy with full read/write access
2668
2710
  - `$connectedCallback` is called **every time** the element is connected (including re-insertion after removal), making it suitable for setup that should be re-established
2669
2711
  - `$disconnectedCallback` is called synchronously — use it for cleanup such as clearing timers, removing event listeners, or releasing resources
2670
- - `$updatedCallback(paths, indexesListByPath)` receives the paths whose live bindings were applied in that drain. Unbound state writes do not invoke it or appear in `paths`. For wildcard updates, `indexesListByPath` contains the updated index sets. Marker paths of mounted components (`#m…`) never appear in `paths` — a component's private keys stay private (DevTools shows them in its overlays view). Can be `async`, but the return value is not awaited
2712
+ - `$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
2671
2713
  - `$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:
2672
2714
 
2673
2715
  ```js
@@ -2680,7 +2722,7 @@ All hooks except `$disconnectedCallback` support `async` — you can use `async/
2680
2722
  };
2681
2723
  ```
2682
2724
 
2683
- The hook runs after the batch (after `$updatedCallback`), is not awaited, and an exception thrown inside it is reported to the console without breaking the drain. DevTools still receives every failure as `state:binding-apply-error` whether or not the hook exists. Root-only: a volume (`<wcs-state mount>`) declaring it is ignored. It does not cover `$watch` handlers (isolated and reported separately) or errors thrown by `$connectedCallback` / `$updatedCallback` (those fail loudly).
2725
+ 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).
2684
2726
  - In Web Components, define `async $stateReadyCallback(stateProp)` to receive a hook when the bound state becomes available via `bind-component`
2685
2727
 
2686
2728
  ## Transition animations
@@ -2703,8 +2745,8 @@ li {
2703
2745
 
2704
2746
  Two consequences to know while that tag accepts the `state` participant:
2705
2747
 
2706
- - The drain lands on a frame instead of a microtask, so code that writes state and then reads the DOM after `await Promise.resolve()` must wait for the transition. `$updatedCallback` still fires immediately after the bindings are applied — its *position* is unchanged, but it moves a frame later along with them.
2707
- - Because `$scan`, `$watch` and the `$streams` restart stay on the original microtask, they now run **before** `$updatedCallback` instead of after it.
2748
+ - 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.
2749
+ - Because `$scan`, `$watch` and the `$stream` restart stay on the original microtask, they now run **before** `$renderedCallback` instead of after it.
2708
2750
 
2709
2751
  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.
2710
2752
 
@@ -2748,7 +2790,7 @@ Anything that follows mechanically from the path string is reported at runtime a
2748
2790
  | `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 |
2749
2791
  | `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 |
2750
2792
  | `wcs/index-param-range` | `$N` must name an existing wildcard level: `$1` through `$128`, no leading zeros | Use a level that exists |
2751
- | `wcs/recursion-unsupported` | `**` reached something that does not interpret it — markup, a `$watch` or `$listKeys` key, `$resolve` / `$postUpdate` / `$trackDependency`, an assignment — or the state declares no `$recursion` at all | Use a concrete path, or declare the anchor |
2793
+ | `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 |
2752
2794
  | `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 |
2753
2795
  | `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 |
2754
2796
  | `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 |
@@ -2774,7 +2816,7 @@ this.$getAll("matrix.*.*", [row]);
2774
2816
 
2775
2817
  ### One failing binding is confined to that binding
2776
2818
 
2777
- If applying a binding throws, the rest of that batch, `$updatedCallback`, `$watch`, and `$streams` restarts all still run. The failure is not swallowed — it goes to `console.error` and to DevTools (`state:binding-apply-error`):
2819
+ 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`):
2778
2820
 
2779
2821
  ```
2780
2822
  [@wcstack/state] binding "text: items.*.label" failed to apply; the rest of this batch continues.
@@ -2782,6 +2824,20 @@ If applying a binding throws, the rest of that batch, `$updatedCallback`, `$watc
2782
2824
 
2783
2825
  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.
2784
2826
 
2827
+ ### A write made while rendering cannot loop forever
2828
+
2829
+ 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:
2830
+
2831
+ ```
2832
+ [@wcstack/state] render chain depth limit exceeded; bindings for this batch were not applied. { maxDepth: 100, paths: ["mode", "view", "view.*", "view.*.m"] }
2833
+ ```
2834
+
2835
+ 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.
2836
+
2837
+ 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`.
2838
+
2839
+ 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.
2840
+
2785
2841
  ### Values and the DOM are never rolled back
2786
2842
 
2787
2843
  Every failure mode reports and continues; nothing already applied is reverted:
@@ -2789,7 +2845,8 @@ Every failure mode reports and continues; nothing already applied is reverted:
2789
2845
  | Mechanism | Limit | On exceeding |
2790
2846
  |---|---|---|
2791
2847
  | Propagation hops | 32 | Quarantine the transaction's remaining records |
2792
- | `$watch` write chain | 32 | Skip watch firing for that batch |
2848
+ | `$watch` write chain | 32 | Skip `$scan` / `$watch` firing for that batch, and the `$stream` restarts its over-limit writes would trigger |
2849
+ | Render write chain | 100 | Skip applying bindings for that batch |
2793
2850
  | Binding apply failure | — | Skip that one binding |
2794
2851
 
2795
2852
  ## Configuration
@@ -3072,7 +3129,7 @@ Subpath entries for tooling: `@wcstack/state/parser` (the `data-wcs` parser as a
3072
3129
  | `setterPaths` | Set of paths defined as setters |
3073
3130
  | `createState(mutability, callback)` | Create a state proxy (`"readonly"` or `"writable"`) |
3074
3131
  | `createStateAsync(mutability, callback)` | Async version of `createState` |
3075
- | `setInitialState(state)` | Set state programmatically. Before initialization it supplies the initial state. On an initialized element it replaces the whole state and re-applies every established binding to the new state before it returns (a detached element re-applies them when it reconnects); a binding whose path the new state no longer has reports a failed apply instead of keeping the old text. A re-set is not a write: it fires no `$watch` handler and no `$updatedCallback`. Lists are matched by array identity, so pass a new array when a list's length changed — re-setting with the same array instance after pushing to or splicing it in place is not supported. Throws on a loaded volume (`<wcs-state mount="…">` — its data was copied into the root tree, so write the paths under the mount path on the root instead), on a tree with grafted volumes or mounted components, and on an element that already failed to initialize — such an element cannot be re-armed; remove it and create a new one |
3132
+ | `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 |
3076
3133
  | `nextVersion()` | Increment and return version number |
3077
3134
 
3078
3135
  ## Architecture
@@ -3206,7 +3263,7 @@ That's it. The client-side `@wcstack/state` automatically detects the `<wcs-ssr>
3206
3263
  |-------|-------------|
3207
3264
  | **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 |
3208
3265
  | **Client** | `<wcs-state enable-ssr>` loads state from `<wcs-ssr>` JSON, skips `$connectedCallback`, and `hydrateBindings()` wires up reactivity on the existing DOM |
3209
- | **Fallback** | If server/client versions mismatch, the SSR DOM is cleaned up and `buildBindings()` runs a full client-side render |
3266
+ | **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 |
3210
3267
 
3211
3268
  ### What `enable-ssr` Does
3212
3269