@wcstack/state 3.3.0 โ†’ 3.5.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 (43) hide show
  1. package/README.ja.md +94 -27
  2. package/README.md +94 -27
  3. package/dist/auto.min.js +1 -1
  4. package/dist/auto.min.js.map +1 -1
  5. package/dist/index.d.ts +52 -1
  6. package/dist/index.esm.js +1 -1
  7. package/dist/index.esm.js.map +1 -1
  8. package/dist/split/chunks/binder.js +1 -1
  9. package/dist/split/chunks/binder.js.map +1 -1
  10. package/dist/split/chunks/checkDependency.js +1 -1
  11. package/dist/split/chunks/checkDependency.js.map +1 -1
  12. package/dist/split/chunks/declarationAliases.js.map +1 -1
  13. package/dist/split/chunks/diagnosticsHooks.js.map +1 -1
  14. package/dist/split/chunks/featureBridge.js +1 -1
  15. package/dist/split/chunks/filterRegistry.js +1 -1
  16. package/dist/split/chunks/filterRegistry.js.map +1 -1
  17. package/dist/split/chunks/getAllPropertyDescriptors.js +1 -1
  18. package/dist/split/chunks/lifecycleHooks.js +1 -1
  19. package/dist/split/chunks/lifecycleHooks.js.map +1 -1
  20. package/dist/split/chunks/pathDiagnostics.js +1 -1
  21. package/dist/split/chunks/pathDiagnostics.js.map +1 -1
  22. package/dist/split/chunks/registerComponents.js +1 -1
  23. package/dist/split/chunks/registerComponents.js.map +1 -1
  24. package/dist/split/chunks/setAll.js +1 -1
  25. package/dist/split/chunks/setAll.js.map +1 -1
  26. package/dist/split/chunks/version.js +1 -1
  27. package/dist/split/core.d.ts +77 -1
  28. package/dist/split/core.js +1 -1
  29. package/dist/split/features/devtools.js +1 -1
  30. package/dist/split/features/devtools.js.map +1 -1
  31. package/dist/split/features/diagnostics.js +1 -1
  32. package/dist/split/features/diagnostics.js.map +1 -1
  33. package/dist/split/features/formats.js +1 -1
  34. package/dist/split/features/formats.js.map +1 -1
  35. package/dist/split/features/recursion.js +1 -1
  36. package/dist/split/features/recursion.js.map +1 -1
  37. package/dist/split/features/scopes.js +1 -1
  38. package/dist/split/features/scopes.js.map +1 -1
  39. package/dist/split/features/ssr.js +1 -1
  40. package/dist/split/features/ssr.js.map +1 -1
  41. package/dist/split/features/temporal.js +1 -1
  42. package/dist/split/features/temporal.js.map +1 -1
  43. package/package.json +1 -1
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  > ๐Ÿค– **AI coding agents**: This README is a package-level reference, not the primary entry point for building a wcstack application. If you have not already done so, first read the repository [README](https://github.com/wcstack/wcstack#readme) and [AGENTS.md](https://github.com/wcstack/wcstack/blob/main/AGENTS.md), then use the [wcstack-app skill](https://github.com/wcstack/wcstack-skill).
4
4
 
5
+ > **Preparing for 4.0**: 4.0 is not released yet. What changes, and what you can already do on 3.x, is in the [3.x โ†’ 4.0 migration guide (preview)](https://github.com/wcstack/wcstack/blob/main/docs/migration-v4.md).
6
+
5
7
  **This is not another convenient frontend framework. It brings a lineage established outside frontend development โ€” the one where a path string is the contract between view and model โ€” onto web standards.**
6
8
 
7
9
  Most libraries place the coupling point between UI, state, and components inside JavaScript. `@wcstack/state` does not. It assumes no virtual DOM, no compilation step, no hooks, no selectors. UI and state are connected by HTML and path strings alone.
@@ -137,7 +139,7 @@ Every row is a section of this README. Unless it appears under [Where the neighb
137
139
  | **Components** | Two mutually exclusive mechanisms: a JavaScript class with `bind-component`, or HTML-only DCC | [Choosing a mechanism](#choosing-a-component-mechanism) |
138
140
  | **Wiring to other elements** | The wc-bindable protocol, spread (`...: obj`), `#init=` / `#sync=` authority, property-to-attribute mirroring | [Binding authority](#binding-authority-init--sync) ยท [Spread](#spread-binding) ยท [Inputs](#inputs-and-attribute-mirror) |
139
141
  | **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** | `$stream` folds an async source, `$watch` reacts headlessly, `$scan` owns an accumulation that outlives both | [Choosing a time mechanism](#choosing-a-time-mechanism) |
142
+ | **Time** | `$stream` folds an async source, `$watch` reacts headlessly, `$scan` owns an accumulation that outlives both (`$scan` warns in 3.5 and is removed in 4.0 โ€” [Preparing for 4.0](#preparing-for-40-wcsv4-migration)) | [Choosing a time mechanism](#choosing-a-time-mechanism) |
141
143
  | **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
144
  | **Diagnostics** | Unresolved paths, index arity, wildcard rank and getter cycles are reported; one failing binding stays confined, and neither values nor the DOM are rolled back | [Diagnostics](#diagnostics-and-failure-handling) |
143
145
  | **Delivery** | Zero runtime dependencies, no build step, ESM, one CDN `/auto` tag; no `unsafe-eval`, Trusted Types supported | [Installation](#installation) ยท [docs/csp.md](../../docs/csp.md) |
@@ -187,15 +189,15 @@ leaves features out, it can compose them instead:
187
189
  <script type="importmap">
188
190
  {
189
191
  "imports": {
190
- "@wcstack/state/core": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.3.0/dist/split/core.js",
191
- "@wcstack/state/features/temporal": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.3.0/dist/split/features/temporal.js",
192
- "@wcstack/state/features/scopes": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.3.0/dist/split/features/scopes.js"
192
+ "@wcstack/state/core": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.5.0/dist/split/core.js",
193
+ "@wcstack/state/features/temporal": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.5.0/dist/split/features/temporal.js",
194
+ "@wcstack/state/features/scopes": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.5.0/dist/split/features/scopes.js"
193
195
  }
194
196
  }
195
197
  </script>
196
198
  <script type="module">
197
199
  import { bootstrapState, installFeatures } from '@wcstack/state/core';
198
- import temporal from '@wcstack/state/features/temporal'; // $watch / $scan / $stream
200
+ import temporal from '@wcstack/state/features/temporal'; // $watch / $stream (and $scan: warns in 3.5, removed in 4.0)
199
201
  import scopes from '@wcstack/state/features/scopes'; // bind-component, mount=, DCC
200
202
 
201
203
  installFeatures([temporal, scopes]);
@@ -220,7 +222,7 @@ so the browser evaluates the engine once. Integrity for this form: [docs/sri.md
220
222
  | Entry | What it adds |
221
223
  |---|---|
222
224
  | `@wcstack/state/core` | The binding engine: `data-wcs`, `for` / `if`, path getters, filters, events, `$command` / `$on`, `bootstrapState`, `installFeatures` |
223
- | `@wcstack/state/features/temporal` | `$watch`, `$scan`, `$stream` |
225
+ | `@wcstack/state/features/temporal` | `$watch`, `$scan` (warns in 3.5, removed in 4.0 โ€” [Preparing for 4.0](#preparing-for-40-wcsv4-migration)), `$stream` |
224
226
  | `@wcstack/state/features/scopes` | `bind-component`, `mount=` volumes, overlay exports, DCC (`data-wc-definition`) |
225
227
  | `@wcstack/state/features/recursion` | `$recursion` and `**` paths |
226
228
  | `@wcstack/state/features/ssr` | `enable-ssr`: server rendering and hydration |
@@ -330,7 +332,9 @@ a feature never carries a second copy of the engine, and never a copy of another
330
332
 
331
333
  Resolution order: `state` โ†’ `src` (.json / .js) โ†’ `json` โ†’ inner `<script>` โ†’ wait for `setInitialState()`.
332
334
 
333
- > **Under a Content-Security-Policy:** form 5 (inline `<script type="module">`) is evaluated through a `blob:` URL and therefore requires `script-src blob:`. A page nonce does not cover it. If you enforce a strict CSP, use form 4 (`src="./state.js"`) instead โ€” it needs no extra directive. See [docs/csp.md](../../docs/csp.md).
335
+ The browser also evaluates form 5's `<script type="module">` itself โ€” being inside `<wcs-state>` does not stop it. Its export goes nowhere, so the state is unaffected, but its top-level code runs twice (once by the browser, once by state). Keep side effects (requests, logging, assignments to globals) out of the top level; under a CSP, see the note below.
336
+
337
+ > **Under a Content-Security-Policy:** form 5 (inline `<script type="module">`) is evaluated through a `blob:` URL and therefore requires either the page's nonce on the `<script>` that loads state (the blob: import inherits it) or `script-src blob:`. If you enforce a strict CSP, use form 4 (`src="./state.js"`) instead โ€” it needs no extra directive. Note that the browser evaluates form 5's `<script>` itself as well: under a CSP that shows up as one violation in the console unless that `<script>` carries the nonce too, and otherwise (with that nonce, or with no CSP) its top-level code runs twice โ€” keep side effects out of it. See [docs/csp.md](../../docs/csp.md).
334
338
 
335
339
  ### Mounting Additional State (`mount=`)
336
340
 
@@ -671,7 +675,7 @@ Runtime reads `customClass.wcBindable.properties + inputs` and expands each name
671
675
  - Filters on the spread target (`...: target|filter`) are rejected.
672
676
  - The right-hand path may contain `*` anywhere (e.g. `...: stores.*.fetch`).
673
677
  - The right-hand side is a plain tree path (`...: fetchX` or `...: stores.*.fetch`).
674
- - If the custom element class is not yet registered, expansion is deferred until `customElements.whenDefined(tag)` resolves โ€” autoloader-style late registration is supported.
678
+ - If the custom element class is not yet registered, expansion is deferred until `customElements.whenDefined(tag)` resolves โ€” autoloader-style late registration is supported. This holds inside `for:` / `if:` templates too: the rows and the branch render right away, and each row's element is expanded with that row's values once the class is defined. A row removed, or a branch closed, before the definition is not expanded. An element taken out of the document while it waits (a tab or panel parked off-DOM) holds nothing in the registry while it is out; once it is back, it waits again on the registry of the tree it is back in, and is expanded when the class is defined (at once if it already is) โ€” for an element of a row, only while that row is still in the list.
675
679
  - Elements **without** a `wcBindable` declaration are rejected (write bindings explicitly). Spread requires the contract to know what to expand.
676
680
 
677
681
  **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.
@@ -1054,7 +1058,7 @@ Two-way binding works with path setters โ€” editing the input calls the setter,
1054
1058
 
1055
1059
  3. **Caching** โ€” Getter results are cached per concrete address (path + loop index). `users.*.fullName` at index 0 has a separate cache entry from index 1. The cache is invalidated only when dependencies change.
1056
1060
 
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.
1061
+ 4. **Direct index access** โ€” You can also access specific elements by numeric index: `this["users.0.name"]` resolves as `users[0].name` without needing loop context. Assigning a different object to an element path (`this["users.0"] = { ...this["users.0"], name: "z" }`) makes that position a new row, whether or not a `for:` renders the list, so the paths below it (`users.0.name`, `$getAll("users.*.name")`, row getters) read the new object instead of the cached values of the row it replaced. Assigning the same object back after changing it in place (`const u = this["users.0"]; u.name = "z"; this["users.0"] = u;`), or announcing the change with `$postUpdate("users.0")` / `$postUpdate("users")`, keeps the row and re-reads the paths below it and the getters that read them, also with no `for:` rendering them inside the row โ€” before this fix those paths kept their cached values unless a `for:` rendered them, and a getter reading `users.0.name` could miss a replaced element when a `$getAll` over the same list was evaluated first. A binding path with one numeric index can use the same spelling, with the same meaning โ€” *whatever row is at index 0 now*: `textContent: users.0.name`, `{{ users.1.name }}`, or a row getter such as `{{ users.0.fullName }}`. It follows a write through an index path (`this["users.0.name"] = โ€ฆ`), a `for:` row or an input bound to that row, replacing the element (`this["users.0"] = {โ€ฆ}`), reordering, removing rows and replacing the list, and a row getter follows its inputs โ€” with or without a `for:` rendering the list. Such a binding is read like a getter, through a non-enumerable accessor added to the state object, and like a getter it is re-evaluated when any row of the list is written; a `$watch` key such as `"users.0.name"` can therefore fire with an unchanged value. An index with no row behind it (an empty list) reads as empty, and a key under an object that is not an array (`sales.2024.total`) reads as a plain key. Through 3.3.0, such a binding kept its first value and followed only replacements of the whole list, and a row getter spelled this way rendered empty with a `wcs/binding-path-missing` warning. A path with two or more numeric indexes (`groups.0.items.1.v`) still behaves that way, as does any numeric-index binding on a state object that cannot be extended (`Object.freeze`, `Object.seal`, `Object.preventExtensions`).
1058
1062
 
1059
1063
  ### Getters must be pure with respect to state
1060
1064
 
@@ -1131,6 +1135,7 @@ What reaches the rows:
1131
1135
  - **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.
1132
1136
  - **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.
1133
1137
  - **A `path` that is a getter, or sits under one** (`$eq("current.id", โ€ฆ)` with `get current()`): its value changes without a write to it, so the calls fall back to an ordinary tracked read. The selection stays correct, but a change re-evaluates every row, as without the keyed form. Point `path` at the written state (`selectedId`) to keep the two-row cost. The State pane of `@wcstack/devtools` counts the subscriptions per path under **Keyed selection** and marks a path that fell back this way with a `tracked` badge (3.0).
1138
+ - **A `path` with a numeric index** (`$eq("items.0.v", โ€ฆ)`): a write reports the index as a wildcard (`items.*.v`, `items.*`), so a subscription under the spelled path would never hear it; these calls fall back to a tracked read too, and follow a write to that row's `v`, a replacement of the element and of the list (a write to another row's `v` also re-evaluates them, as with any getter). Before this fix such a getter kept its first value unless a binding on the same numeric path existed.
1134
1139
  - **No type conversion:** `"2"` does not match the id `2`. An `<input>` or `<select>` writes strings, so convert on the way in (`value|number: selectedId`) or keep the ids as strings.
1135
1140
  - **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.
1136
1141
 
@@ -1280,6 +1285,12 @@ export default {
1280
1285
 
1281
1286
  Swapping rows this way moves the rendered rows with their values once the swap is complete: the row blocks are reordered rather than rewritten in place, so a row's `$1` and any state it holds outside bindings (such as text typed into an unbound input) follow the value. Writing a value that was not in the list replaces that row in place: its block stays where it is and its bindings show the new value, so an input bound to the row keeps focus while you type. In a list of primitives, equal values cannot be told apart, so writes that end in a reordering of the same values count as a swap.
1282
1287
 
1288
+ The swap is complete once every value you moved has left the position it came from. Until then โ€” between the two writes, while the moved value sits at both positions โ€” the rows keep their blocks and show the values by position, and a numeric index (`this["items.0"]`) reads and writes that position. The two writes may also run in separate updates (two clicks, say): the update that completes the swap reports to a `$watch` on `items.*` only the position it wrote, with that position's `prev`. Assigning an array before the swap completes โ€” even an array the path held before โ€” renders that array's values. A list that holds equal values in separate rows swaps like any other, but a write that copies a value another row keeps holding cannot be told apart from the first half of a swap. So once your code leaves rows holding equal values for good, its index writes into that array are rendered by position until the array is replaced: the values and `$1` are right, but the blocks stop following the values.
1289
+
1290
+ None of this applies to a value that an element inside a row writes to its own row โ€” an input bound with `value: .`, or a wc-bindable output bound with `status: .`. That write updates the row's value and never counts as part of a swap: the row keeps its block and its `$1`, and the element's later writes keep landing on its own row, even when several rows report the same value or one row's element writes a value another row holds.
1291
+
1292
+ **One object at two positions.** When the same object sits at two positions of a list (`items: [o, o, โ€ฆ]`), a write to a key below one of those rows (`this["items.0.name"] = "z"`) changes the object, but the other row keeps showing and reading the old value: values below a row are cached per row, and a write reaches only the row it was made through. Call `$postUpdate("items")` after such a write, and every row โ€” the `for:` rows, `$getAll("items.*.name")` and index paths such as `items.1.name` โ€” reads the object again.
1293
+
1283
1294
  ## Recursive Paths (`$recursion`)
1284
1295
 
1285
1296
  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".
@@ -1429,7 +1440,9 @@ The read-only rule does not depend on spelling `**`. A recursive getter's concre
1429
1440
 
1430
1441
  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.
1431
1442
 
1432
- 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.
1443
+ Replacing a row object while keeping its `children` array โ€” `this.nodes = this.nodes.map(n => ({ ...n }))` โ€” is an ordinary update, and the aggregates follow it. The child list keeps its existing row objects โ€” so anything keyed by row identity, such as a `bind-component` child scope's rendered rows and any state you have not bound there, survives โ€” and only the retired row they hung under is swapped for the live one, so the next leaf update dirties the row that is actually on screen ([#256](https://github.com/wcstack/wcstack/issues/256)). Two rows *sharing* one `children` array is a different thing. While both rows are in the list it is unchanged: an array has one set of rows, so both rows always agree on every value, and a row getter that reads its parent (`this["nodes.*.value"]`) is evaluated in the context of the row that owns those rows โ€” the one that first expanded the array. What changed is what happens when that owner is removed from the list: the rows follow one of the rows still in the list (it can be a row that `if:` hides), so that row's aggregate tracks the shared data instead of freezing at the removed row's numbers โ€” an array still has one set of rows, so with three rows sharing one array a single survivor follows and the others stay frozen. Since 3.4.0 the same hand-over happens when the owner stays in the list but stops holding the array (`this["nodes.0.children"] = [ โ€ฆ ]`): the rows go to another row that holds it under the same key, and that row's aggregate tracks the shared data โ€” 3.3.0 left it frozen. Putting the removed row back hands them straight back to it when that row's own object comes back โ€” whether you reassign the same array instance, build a new array holding the same rows, or put the row back at a different position. When every row is rebuilt instead (`this.nodes = this.nodes.map(n => ({ ...n }))`), no row object matches and the rows end up under whichever row now occupies the owner's old position. Only the same-array-instance restore behaves this way on 2.3.0; restoring with a new array leaves both rows frozen there. Give every node its own array when a child getter reads upward: after the owner stops holding the array, such a getter can keep returning values computed for the old owner until something it reads changes.
1444
+
1445
+ Three sharing shapes are known limits. When different outer rows hold the same array under **different keys** (one as `items`, another as `alt`), a write through one key can land on another element ([#396](https://github.com/wcstack/wcstack/issues/396)). Do not share one array between two `<wcs-state>` elements, a root and its volume included: after a copy or a rebuild in one, a write through the other can land on the wrong array, or throw and be lost ([#397](https://github.com/wcstack/wcstack/issues/397)). And stacking copies, moves and rebuilds of shared arrays in one update can leave some `for:` blocks showing old values while the state is right ([#398](https://github.com/wcstack/wcstack/issues/398)).
1433
1446
 
1434
1447
  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.
1435
1448
 
@@ -1562,7 +1575,7 @@ export default {
1562
1575
  | `capitalize` (`cap`) | Capitalize | `name\|capitalize` |
1563
1576
  | `trim` | Trim whitespace | `text\|trim` |
1564
1577
  | `slice(n)` | Slice string | `text\|slice(5)` |
1565
- | `substr(start, length)` | Substring (both arguments required) | `text\|substr(0,10)` |
1578
+ | `substr(start, length)` | Substring (both arguments required). Warns in 3.5 and is removed in 4.0: write `slice(start, start + length)` โ€” [Preparing for 4.0](#preparing-for-40-wcsv4-migration) | `text\|substr(0,10)` |
1566
1579
  | `padStart(n, char?)` (`pad`) | Pad the start (default `0`) | `id\|padStart(5,0)` โ†’ `"00001"` |
1567
1580
  | `padEnd(n, char?)` | Pad the end (default a space; 3.2) | `code\|padEnd(8)` |
1568
1581
  | `repeat(n)` (`rep`) | Repeat | `text\|repeat(3)` |
@@ -1735,6 +1748,7 @@ customElements.define("user-card", UserCard);
1735
1748
  - 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`).
1736
1749
  - 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.*`.
1737
1750
  - **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.
1751
+ - 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.
1738
1752
  - 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.
1739
1753
 
1740
1754
  > The per-property form (`state.message: user.name`) keeps working โ€” it is a partial mount on
@@ -1828,6 +1842,8 @@ customElements.define("my-component", MyComponent);
1828
1842
  </template>
1829
1843
  ```
1830
1844
 
1845
+ A component in a row reads and writes its own row. The `this` of its `$connectedCallback` / `$disconnectedCallback` โ€” and a `this` kept from them in a timer or across an `await` โ€” follows the element's row: it keeps writing to that row while an `if:` hides the element, and moves to the new row when an element write (`users[1] = โ€ฆ`) replaces the row in place. Once the element has been taken out and reused for another row, a kept `this` throws `The host row of <user-row> was removed.` instead of writing to either row. When the row itself is removed, `$disconnectedCallback` can still read and write the component's own keys (clear the timer id it kept in `this.tid`); reading the row's keys (`this.name`) throws the same error. A partial mount (`state.name: .name`) keeps one set of own keys per element, not per row, so what its `$disconnectedCallback` writes there is still there when the element is reused for another row.
1846
+
1831
1847
  ### Rendering a List Inside the Component
1832
1848
 
1833
1849
  An array can be bound into a component and iterated with `for:` **inside** it. The outer state
@@ -2179,7 +2195,7 @@ The next four sections answer four different questions, and the usual mistake is
2179
2195
  | [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 |
2180
2196
  | [`$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 |
2181
2197
  | [`$watch`](#watch-watch) | A reaction to a change | No | Once per batch per changed address, after the scan write | Side effects, "when this becomes true" |
2182
- | [`$scan`](#scan-scan) | An accumulation over time, and what resets it | Yes โ€” the runtime owns the output | Once per landing (`from`) or once per event (`on`) | Paging accumulation, history, counters |
2198
+ | [`$scan`](#scan-scan) | An accumulation over time, and what resets it | Yes โ€” the runtime owns the output | Once per landing (`from`) or once per event (`on`) | Paging accumulation, history, counters. Warns in 3.5 and is removed in 4.0: fold in a `$watch` (state paths) or `$on` (event tokens) โ€” [Preparing for 4.0](#preparing-for-40-wcsv4-migration) |
2183
2199
 
2184
2200
  Two rules cut most of the confusion:
2185
2201
 
@@ -2302,9 +2318,9 @@ $renderedCallback(paths) {
2302
2318
  }
2303
2319
  ```
2304
2320
 
2305
- **The rule:** logic that must not depend on what is rendered belongs on a `$watch`, a `$scan`, or a `$stream` `args`. Keep `$renderedCallback` for "follow what was drawn".
2321
+ **The rule:** logic that must not depend on what is rendered belongs on a `$watch`, a `$scan`, or a `$stream` `args`. Keep `$renderedCallback` for "follow what was drawn". (`$scan` warns in 3.5 and is removed in 4.0; a `$watch` or an `$on` handler covers the same ground โ€” [Preparing for 4.0](#preparing-for-40-wcsv4-migration).)
2306
2322
 
2307
- That example now accumulates its feed with `$scan` (and re-arms the sentinel from a `$watch`), and the `<b>` is display-only again. This shape โ€” `$renderedCallback` testing a path that is not bound anywhere โ€” is detected statically as **`wcs/updated-callback-unbound`**.
2323
+ That example now folds each landed page into its feed from a `$watch` on the stream's value (and re-arms the sentinel from a `$watch` on the feed), and the `<b>` is display-only again. This shape โ€” `$renderedCallback` testing a path that is not bound anywhere โ€” is detected statically as **`wcs/updated-callback-unbound`**.
2308
2324
 
2309
2325
  ### The limitation that remains
2310
2326
 
@@ -2371,14 +2387,16 @@ Key rules:
2371
2387
  - **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.
2372
2388
  - **Rows follow the list as it stands at the drain** โ€” a row written and then removed, replaced or cut off in the same job does not fire, a row that only moved into another position does not fire, and each position fires at most once. Replacing a nested list fires for every row of the new array.
2373
2389
  - **Row-level diffs want `$listKeys`** โ€” without it, assigning a whole array fires the row watch for *every* row with `prev === undefined`, because no row went through a path write. With `$listKeys` declared, the key match decomposes the assignment into per-field writes, so only changed rows fire and `prev` is a real scalar.
2374
- - **A headless row watch requires `$listKeys`** โ€” this is the one place `$watch` is *not* headless on its own. Expanding `items` into `items.*.price` is driven by the list's `for` binding, and declaring a watch deliberately does not register the path as a list. So with neither a `for` binding nor `$listKeys`, assigning the array fires the row watch **zero** times. Add `$listKeys` (the key match writes each field by path, bypassing the expansion) or render the list. Scalar paths โ€” including nested ones like `user.name` โ€” are headless with no such condition.
2390
+ - **A headless row watch requires `$listKeys`** โ€” this is the one place `$watch` is *not* headless on its own. Expanding `items` into `items.*.price` is driven by the list's `for` binding, and declaring a watch deliberately does not register the path as a list. So with neither a `for` binding nor `$listKeys`, assigning the array fires the row watch **zero** times. Add `$listKeys` (the key match writes each field by path, bypassing the expansion) or render the list. Scalar paths โ€” including nested ones like `user.name` โ€” are headless with no such condition. Without a `for`, reassigning the same array or a copy of it and `$postUpdate("items")` do not fire it either: they only make the row values be read again, so a getter over them (`$getAll("items.*.price", [])`) sees values changed in place.
2375
2391
  - **Handler exceptions are isolated** โ€” a throw is reported to the console and the remaining watches (and stream restarts) still run. This differs from `$connectedCallback` / `$renderedCallback`, which fail loudly.
2376
- - **Write chains are bounded** โ€” a handler's writes form a new batch, so mutually-writing watches would loop forever; the chain is cut off after 32 links with a console error. Values and DOM are not rolled back.
2392
+ - **Write chains are bounded** โ€” a handler's writes form a new batch, so mutually-writing watches would loop forever; the chain is cut off after 32 links with a console error. Values and DOM are not rolled back. Links are counted per write: a handler (or a `$scan` fold) starts from the depth of the write that triggered it, so only a handler's write that triggers another handler extends the chain. A handler that follows a long finite render chain โ€” `$renderedCallback` shrinking a heading one step per render โ€” is not cut off, even when its own writes ride in the same batches. A `$stream` restart counts like a handler: its writes (the reset to `initial`, the status) continue the chain of the write that triggered it, and a restart triggered by a write past the limit is not run. So a `$watch` that writes a stream's `args` and is woken again by the restart is cut off, and so are streams whose `args` read each other โ€” the batch past the limit is reported even when no `$watch` is declared.
2377
2393
  - **Not run on a mounted `bind-component` scope** โ€” mounted components do not execute declaration surfaces: the `$watch` declaration is ignored with a one-time console warning that points to the root state (or a volume โ€” `<wcs-state mount>` hosts `$watch` / `$listKeys` / `$renderedCallback`). This applies to `$stream` too. A plain (unwired Shadow) child owns an independent tree and can declare it.
2378
2394
  - **SSR does not run watches** โ€” handler side effects would otherwise execute on both server and client.
2379
2395
 
2380
2396
  ## Scan (`$scan`)
2381
2397
 
2398
+ > **`$scan` warns in 3.5 and is removed in 4.0.** Fold a state path from a `$watch` handler that writes the accumulated value, and an event token from an `$on` handler; keep the reset condition in the handler. See [Preparing for 4.0](#preparing-for-40-wcsv4-migration).
2399
+
2382
2400
  `$stream` folds *within* one run โ€” every restart resets the value to `initial` โ€” and `$watch` owns no value. **`$scan`** declares the value that has to outlive both: an accumulation over time, with an owner, a firing unit and a reset condition.
2383
2401
 
2384
2402
  ```html
@@ -2812,6 +2830,20 @@ If applying a binding throws, the rest of that batch, `$renderedCallback`, `$wat
2812
2830
 
2813
2831
  Without that confinement, a single throw left "new values, half-updated DOM" behind, and silently dropped every `$watch` handler and stream restart for the batch โ€” quietly breaking the firing-order contract documented above.
2814
2832
 
2833
+ ### A write made while rendering cannot loop forever
2834
+
2835
+ Some writes happen while bindings are being applied: a wc-bindable element in a new `for:` row hands its initial value to the state (output-only members, `#init=element`), and `$renderedCallback` may write. Each such write starts a new batch, and that batch renders again. If the rendering writes something new every time โ€” say a row element whose output is bound to the very key the list's getter reads, returning a different value per instance โ€” the page would spin in microtasks and freeze. The chain is cut off after 100 links: the batch that would be the 101st is not rendered (its values stay in the state), and one report names the paths it carried:
2836
+
2837
+ ```
2838
+ [@wcstack/state] render chain depth limit exceeded; bindings for this batch were not applied. { maxDepth: 100, paths: ["mode", "view", "view.*", "view.*.m"] }
2839
+ ```
2840
+
2841
+ The limit is higher than the other two limits (32) because each link here is a whole render. A chain that renders, measures and adjusts can legitimately run past 32 links and still settle โ€” shrinking a heading 1px at a time from 64px to 18px renders 47 times, and drawing 400 rows 10 at a time is 40 links.
2842
+
2843
+ Only writes made synchronously while bindings are being applied extend the chain โ€” including an event that an element dispatches synchronously while a binding sets one of its properties. A write that arrives from outside the drain starts the count again from zero: a user action, an I/O node event that arrives asynchronously, a `$stream` value, an `await` continuation. Writes from `$scan`, `$watch` and `$stream` restarts neither extend nor reset it โ€” they carry the chain over to the batch they start. So a cycle that goes through `$watch` or `$scan` (an element writes `x`, `$watch: { x(v) { this.mode = v } }`, and the list reads `mode`) is cut off too, one link per render; the `$watch` limit does not catch it, because the handler's write comes back through a render rather than straight into another handler. A write-back that settles, such as the initial sync of an ordinary list's rows, adds one link and stops. Because the chain is carried over, finite chains joined by `$watch` or `$scan` share the 100 links: a heading fit that takes 47 links, followed by a second one that a `$watch` starts when the first finishes, settles (94 renders), but a third one joined the same way is cut off partway. The chain is reported once: when the batch that was not rendered still hands the chain on through `$watch` or `$scan`, the batches that follow are not rendered either, without another report. The next write from outside renders normally. DevTools receives `state:render-chain-limit`.
2844
+
2845
+ A loop whose closing write arrives from a microtask is outside this limit, as it was before the limit existed: an element that reports its value from a microtask (Lit's `updated()`, for example) and an async `$renderedCallback` that writes after an `await`. Such a write cannot be told apart from a finite async loop โ€” an `async` function that writes 10,000 times with an `await` of a resolved promise between the writes also renders 10,000 times without a single macrotask โ€” so a limit on renders in a row would stop legitimate code as well.
2846
+
2815
2847
  ### Values and the DOM are never rolled back
2816
2848
 
2817
2849
  Every failure mode reports and continues; nothing already applied is reverted:
@@ -2819,9 +2851,43 @@ Every failure mode reports and continues; nothing already applied is reverted:
2819
2851
  | Mechanism | Limit | On exceeding |
2820
2852
  |---|---|---|
2821
2853
  | Propagation hops | 32 | Quarantine the transaction's remaining records |
2822
- | `$watch` write chain | 32 | Skip watch firing for that batch |
2854
+ | `$watch` write chain | 32 | Skip `$scan` / `$watch` firing for that batch, and the `$stream` restarts its over-limit writes would trigger |
2855
+ | Render write chain | 100 | Skip applying bindings for that batch |
2823
2856
  | Binding apply failure | โ€” | Skip that one binding |
2824
2857
 
2858
+ ### Preparing for 4.0 (`wcs/v4-migration`)
2859
+
2860
+ 3.5 is the last 3.x minor. It names each name and option that 4.0 removes or moves, and still runs it the 3.x way. Each warning is a `console.warn` under the code `wcs/v4-migration`, printed once per name per page. It says what 4.0 does and what to write instead:
2861
+
2862
+ ```
2863
+ [@wcstack/state] [wcs/v4-migration] filter "uc" is removed in 4.0: write "upper" (its name since 3.2).
2864
+ See "Preparing for 4.0" in the @wcstack/state README.
2865
+ ```
2866
+
2867
+ | Form | 3.x | 4.0 | Write instead |
2868
+ |---|---|---|---|
2869
+ | The filter names 3.2 renamed: `inc` `dec` `fix` `uc` `lc` `cap` `rep` `rev` `pad` `null` | Aliases | `[wcs/filter-unknown]` | `add` `sub` `toFixed` `upper` `lower` `capitalize` `repeat` `reverse` `padStart` `nullIfEmpty` |
2870
+ | `$trackDependency` / `$untrackDependency` | Aliases | Throws `[wcs/name-alias]` | `$dependOn` / `$untracked` |
2871
+ | `$updatedCallback` / `$streams` | Aliases | Throws `[wcs/declaration-alias]` | `$renderedCallback` / `$stream` |
2872
+ | `substr(start, length)` | Works | `[wcs/filter-unknown]` | `slice(start, start + length)` (the end index, not a length) |
2873
+ | `$scan` | Works | Throws | `$watch` for state paths, `$on` for event tokens |
2874
+ | `bootstrapState({ debug })`, `commentTextPrefix`, `enablePropagationContext` | Work | Throw | Remove them. 4.0 reads comment bindings as `<!--@@: path-->` and `<!--@@wcs-text: path-->` only |
2875
+ | `bootstrapState({ enableMustache })`, `sameValueGuard`, `enableDirectionalInitialSync` | Page-wide options | Throw; each state tree declares them in `$behavior` | Keep them in 3.x. When you upgrade, move them to `$behavior` in each root and component state (a component does not inherit its host's; a volume may not declare one) |
2876
+ | An unknown `bootstrapState` key, or a value of another type (`null`, an array, a non-string tag name, a `tagNames` key other than `state` / `ssr`) | Ignored | Throws | Remove or fix it |
2877
+ | A state key `$behavior` | Plain data; 3.x does not read it | The state's behavior options | Warned only when its value differs from the option 3.x runs with, or has a shape 4.0 throws on |
2878
+ | A state key `$features` | Plain data | The add-ons the state needs | Warned only when 4.0 would throw on it (not an array of add-on names) |
2879
+ | A volume (`<wcs-state mount>`) that declares `$watch`, `$listKeys` or `$renderedCallback` (`$updatedCallback`) | Run relative to the mount path | The volume is not grafted (`console.error`) | Move them to the root state, with absolute paths |
2880
+ | A volume that injects root paths (`data-wcs="state.<key>: โ€ฆ"`) | Works (3.1) | The volume is not grafted (`console.error`) | Read the root path in a root getter |
2881
+ | A volume that declares `$behavior` / `$features` | Plain data, grafted | The volume is not grafted (`console.error`) | Declare them on the root state |
2882
+
2883
+ Where each fires: an old declaration key, `$scan`, `$behavior` and `$features` when the state object is loaded (a volume's when it is grafted); an old filter name and `substr` when a binding that uses it is set up; a `bootstrapState` option when `bootstrapState()` is called; `$trackDependency` / `$untrackDependency` when they are read. The checks sit where an old form is resolved, so a page that writes the canonical names behaves exactly as before; what it runs differs by one comparison where a filter is set up and one where `$dependOn` / `$untracked` is read, with no measurable difference.
2884
+
2885
+ The warnings ship in the full entries (`@wcstack/state` and `/auto`). A page on the split entries (`@wcstack/state/core` with `features/*`) prints none. There is no other switch, as in 2.6. A warning fires only on the path that reaches the old form, so a clean console proves nothing about the paths the page did not run. `npx @wcstack/lint <file>` and the VS Code extension report the old names statically (`wcs/name-alias`, info, since 3.2).
2886
+
2887
+ The three options that move to `$behavior` keep warning while a page passes them: 3.x reads them only from `bootstrapState`, so there is nothing to change before the upgrade. Writing `$behavior` early is harmless and is checked against the options 3.x runs with. One 4.0 rule has no 3.5 warning: a re-set (`setInitialState()` on an initialized element) may not change `$behavior` โ€” 4.0 throws (#45), so create the element again instead.
2888
+
2889
+ `on*#direct:` works in 3.x already. 4.0 delegates `on*:` to the root and adds `#direct` to attach the listener to the element. 3.x attaches every `on*:` to its element and accepts the `#direct` modifier silently. Write it now where the element-attached behavior matters: `onclick#direct,stop:` stops your own listeners on the ancestors, and the handler still runs when an ancestor calls `stopPropagation()`.
2890
+
2825
2891
  ## Configuration
2826
2892
 
2827
2893
  Pass a partial configuration object to `bootstrapState()`:
@@ -2831,8 +2897,7 @@ import { bootstrapState } from '@wcstack/state';
2831
2897
 
2832
2898
  bootstrapState({
2833
2899
  locale: 'ja-JP',
2834
- debug: true,
2835
- enableMustache: false,
2900
+ bindAttributeName: 'data-wcs',
2836
2901
  tagNames: { state: 'my-state' },
2837
2902
  });
2838
2903
  ```
@@ -2845,12 +2910,14 @@ All options with defaults:
2845
2910
  | `tagNames.state` | `'wcs-state'` | State element tag name |
2846
2911
  | `tagNames.ssr` | `'wcs-ssr'` | Tag name of the SSR hydration-data element |
2847
2912
  | `locale` | `<html lang>`, else `'en'` | Locale for the locale-dependent filters (`locale` / `date` / `time` / `datetime`) โ€” see [Locale](#locale) |
2848
- | `debug` | `false` | Debug mode |
2849
- | `enableMustache` | `true` | Enable `{{ }}` syntax |
2850
- | `enableDirectionalInitialSync` | `true` | Direction-aware binding authority (`#init=` / `#sync=` binding modifiers) โ€” see [Binding Authority](#binding-authority-init--sync). Default on; set `false` to opt out |
2851
- | `enablePropagationContext` | `true` | Causal propagation tracking across bindings (echo/diamond loop prevention). Default on; set `false` to opt out |
2913
+ | `debug` | `false` | Debug mode. Removed in 4.0 (3.5 warns) |
2914
+ | `enableMustache` | `true` | Enable `{{ }}` syntax. Moves to the state's `$behavior` in 4.0 (3.5 warns) |
2915
+ | `enableDirectionalInitialSync` | `true` | Direction-aware binding authority (`#init=` / `#sync=` binding modifiers) โ€” see [Binding Authority](#binding-authority-init--sync). Default on; set `false` to opt out. Moves to the state's `$behavior` in 4.0 (3.5 warns) |
2916
+ | `enablePropagationContext` | `true` | Causal propagation tracking across bindings (echo/diamond loop prevention). Default on; set `false` to opt out. Removed in 4.0 (3.5 warns) |
2852
2917
  | `enableContractAnalyzer` | `false` | Opt-in dev-time contract analyzer (exposes `analyzeContract`) |
2853
- | `sameValueGuard` | `true` | Drop a primitive write whose value is `Object.is`-equal to the current one before anything is enqueued โ€” bindings and `$watch` effectively fire on change only; reference types always pass. `false` lets equal writes through and makes `$watch`'s `prev` `undefined` |
2918
+ | `sameValueGuard` | `true` | Drop a primitive write whose value is `Object.is`-equal to the current one before anything is enqueued โ€” bindings and `$watch` effectively fire on change only; reference types always pass. `false` lets equal writes through and makes `$watch`'s `prev` `undefined`. Moves to the state's `$behavior` in 4.0 (3.5 warns) |
2919
+
2920
+ 4.0 removes `debug`, `commentTextPrefix` and `enablePropagationContext`, and moves `enableMustache`, `enableDirectionalInitialSync` and `sameValueGuard` to the state's `$behavior`. 3.5 warns when a page passes them โ€” see [Preparing for 4.0](#preparing-for-40-wcsv4-migration).
2854
2921
 
2855
2922
  ### Locale
2856
2923
 
@@ -3085,7 +3152,7 @@ Subpath entries for tooling: `@wcstack/state/parser` (the `data-wcs` parser as a
3085
3152
  | Attribute | Description |
3086
3153
  |---|---|
3087
3154
  | `mount` | Static tree path to graft this state onto the root tree as a **volume** (v2 โ€” replaces the removed `name` attribute; one state tree per root) |
3088
- | `state` | ID of a `<script type="application/json">` element |
3155
+ | `state` | ID of a `<script type="application/json">` element in the document (looked up with `document.getElementById`, so a script inside a shadow root is not found) |
3089
3156
  | `src` | URL to `.json` or `.js` file |
3090
3157
  | `json` | Inline JSON string |
3091
3158
  | `bind-component` | Property name for web component binding |
@@ -3096,7 +3163,7 @@ Subpath entries for tooling: `@wcstack/state/parser` (the `data-wcs` parser as a
3096
3163
  | Property / Method | Description |
3097
3164
  |---|---|
3098
3165
  | `initializePromise` | Resolves when state is fully initialized โ€” and also **when initialization fails**, so one element's failure never blocks the rest of the page's bindings; the error is delivered on `connectedCallbackPromise` |
3099
- | `connectedCallbackPromise` | Resolves once `connectedCallback` has completed (state loaded, `$connectedCallback` run) โ€” what the testing recipes await. A **root** element that fails to initialize **rejects** it with the original error, unwrapped, and reports the failure once with `console.error`: an invalid `$` declaration, a source it cannot load, the SSR data merge, a DCC or `bind-component` setup error, or a second root `<wcs-state>` on the same root node (that second element stays unregistered but keeps the state it loaded, so remove it; moving a healthy element in the DOM is not a duplicate and is never refused). A **volume** (`<wcs-state mount="โ€ฆ">`) never rejects it โ€” a volume failure resolves it instead, and some volume failures report nothing of their own: the error leaves as the `connectedCallback` promise that custom-element reactions discard, which a browser console shows as "Uncaught (in promise)" but nothing awaiting these promises (a test recipe, `renderToString()`) ever sees. Detaching an element while its source is still loading rejects nothing โ€” that connection just ends, and re-appending the element (row pooling) initializes it and resolves normally. For the exact behaviour of any single failure site, read `__tests__/integration.initFailureDiagnostics.test.ts`: it pins every case |
3166
+ | `connectedCallbackPromise` | Resolves once `connectedCallback` has completed (state loaded, `$connectedCallback` run) โ€” what the testing recipes await. A **root** element that fails to initialize **rejects** it with the original error, unwrapped, and reports the failure once with `console.error`: an invalid `$` declaration, a source it cannot load, the SSR data merge, a DCC or `bind-component` setup error, or a second root `<wcs-state>` on the same root node (that second element stays unregistered but keeps the state it loaded, so remove it; moving a healthy element in the DOM is not a duplicate and is never refused). Two sources are not failures in 3.x and resolve it instead: a `src="*.json"` that cannot be fetched or parsed (logged with `console.error`, with its URL and the HTTP status or the error; the element starts with an empty state, and a volume grafts `{}`), and a `state="<id>"` with no `<script type="application/json">` of that id in the document (one `console.warn`; the state starts empty โ€” the lookup is `document.getElementById`, so a script inside a shadow root is not found). 4.0 rejects in both cases; for `state=` it searches the element's own root first, then the document, and rejects only when the id is in neither. A **volume** (`<wcs-state mount="โ€ฆ">`) never rejects it โ€” a volume failure resolves it instead, and some volume failures report nothing of their own: the error leaves as the `connectedCallback` promise that custom-element reactions discard, which a browser console shows as "Uncaught (in promise)" but nothing awaiting these promises (a test recipe, `renderToString()`) ever sees. Detaching an element while its source is still loading rejects nothing โ€” that connection just ends, and re-appending the element (row pooling) initializes it and resolves normally. For the exact behaviour of any single failure site, read `__tests__/integration.initFailureDiagnostics.test.ts`: it pins every case |
3100
3167
  | `listPaths` | Set of paths used in `for` loops |
3101
3168
  | `getterPaths` | Set of paths defined as getters |
3102
3169
  | `setterPaths` | Set of paths defined as setters |
@@ -3236,7 +3303,7 @@ That's it. The client-side `@wcstack/state` automatically detects the `<wcs-ssr>
3236
3303
  |-------|-------------|
3237
3304
  | **Server** | `renderToString()` runs your template in happy-dom, executes `$connectedCallback` (including `fetch()`), applies all bindings, and outputs rendered HTML with a `<wcs-ssr>` element containing hydration data |
3238
3305
  | **Client** | `<wcs-state enable-ssr>` loads state from `<wcs-ssr>` JSON, skips `$connectedCallback`, and `hydrateBindings()` wires up reactivity on the existing DOM |
3239
- | **Fallback** | If server/client versions mismatch, the SSR DOM is cleaned up and `buildBindings()` runs a full client-side render |
3306
+ | **Fallback** | If server/client versions mismatch, or a server-rendered `for:` row sits inside another `for:` / `if:` / `elseif:` / `else:` block (hydrating those is a known limitation), the SSR DOM is cleaned up and `buildBindings()` runs a full client-side render. Either case logs one `console.warn` saying why |
3240
3307
 
3241
3308
  ### What `enable-ssr` Does
3242
3309