@wcstack/state 1.30.0 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -769,19 +769,20 @@ export default {
769
769
  + this["regions.*.prefectures.*.cities.*.name"];
770
770
  },
771
771
 
772
- // Prefecture level — aggregate from cities
772
+ // Prefecture level — aggregate from cities. `indexes` omitted: it defaults to
773
+ // the loop context ([$1, $2]), so only this prefecture's cities are summed
773
774
  get "regions.*.prefectures.*.totalPopulation"() {
774
- return this.$getAll("regions.*.prefectures.*.cities.*.population", [])
775
+ return this.$getAll("regions.*.prefectures.*.cities.*.population")
775
776
  .reduce((a, b) => a + b, 0);
776
777
  },
777
778
 
778
- // Region level — aggregate from prefectures
779
+ // Region level — aggregate from prefectures (context [$1] narrows to this region)
779
780
  get "regions.*.totalPopulation"() {
780
- return this.$getAll("regions.*.prefectures.*.totalPopulation", [])
781
+ return this.$getAll("regions.*.prefectures.*.totalPopulation")
781
782
  .reduce((a, b) => a + b, 0);
782
783
  },
783
784
 
784
- // Top level — aggregate from regions
785
+ // Top level — no loop context; [] means "every match"
785
786
  get totalPopulation() {
786
787
  return this.$getAll("regions.*.totalPopulation", [])
787
788
  .reduce((a, b) => a + b, 0);
@@ -872,6 +873,36 @@ Two-way binding works with path setters — editing the input calls the setter,
872
873
 
873
874
  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.
874
875
 
876
+ ### Getters must be pure with respect to state
877
+
878
+ A getter's cache is invalidated **only** through the dependency graph, and the graph only records what the getter read **through `this`**. Anything else a getter reads is invisible to invalidation, so the first value computed is the value you keep:
879
+
880
+ ```javascript
881
+ // ❌ Never recomputes — nothing in the dependency graph ever changes
882
+ get stamp() { return `${this.label} @ ${Date.now()}`; } // Date.now() is untracked
883
+ get theme() { return document.body.dataset.theme; } // the DOM is untracked
884
+ get total() { return this.price * exchangeRate; } // a module variable is untracked
885
+ ```
886
+
887
+ The rule: **read only through `this`, and don't write state or touch the DOM from a getter.** For the cases where an untracked input genuinely has to participate, put the input into state and assign to it (the normal path-assignment contract), or use the escape hatches:
888
+
889
+ | API | Use it for |
890
+ |---|---|
891
+ | `this.$trackDependency(path)` | Register an extra dependency so this getter is dirtied when that path changes |
892
+ | `this.$postUpdate(path)` | Announce that an untracked input changed, from outside the getter |
893
+ | `this.$untrackDependency(fn)` | Read a path *without* registering it as a dependency (the inverse) |
894
+
895
+ ```javascript
896
+ // ✅ The clock ticks in state; the getter stays pure
897
+ export default {
898
+ now: Date.now(),
899
+ get stamp() { return `${this.label} @ ${this.now}`; },
900
+ $connectedCallback() { setInterval(() => { this.now = Date.now(); }, 1000); },
901
+ };
902
+ ```
903
+
904
+ Getters that throw are not swallowed: the exception surfaces where the getter was evaluated (a binding apply, a `$watch` evaluation, or your own read).
905
+
875
906
  ### Loop Index Variables (`$1`, `$2`, ...)
876
907
 
877
908
  Inside getters and event handlers, `this.$1`, `this.$2`, etc. provide the current loop iteration index (0-based value, 1-based naming):
@@ -913,6 +944,7 @@ Inside state objects (getters / methods), the following APIs are available via `
913
944
  | API | Description |
914
945
  |---|---|
915
946
  | `this.$getAll(path, indexes?)` | Get all values matching a wildcard path |
947
+ | `this.$setAll(path, indexes, value, options?)` | Write to every address matching a wildcard path |
916
948
  | `this.$resolve(path, indexes, value?)` | Resolve a wildcard path with specific indexes |
917
949
  | `this.$postUpdate(path)` | Manually trigger update notification for a path |
918
950
  | `this.$trackDependency(path)` | Manually register a dependency for cache invalidation |
@@ -938,6 +970,66 @@ export default {
938
970
  };
939
971
  ```
940
972
 
973
+ `indexes` is a **prefix** over the path's wildcards: missing levels expand fully, and `[]` always means "every match". When `indexes` is **omitted**, it defaults to the enclosing loop context (`[$1, $2, ...]`), applied to the wildcard levels the path shares with that context:
974
+
975
+ ```javascript
976
+ export default {
977
+ regions: [ /* { prefectures: [ { population: … }, … ] } */ ],
978
+ // Loop context [$1] — omission narrows to the current region
979
+ get "regions.*.total"() {
980
+ return this.$getAll("regions.*.prefectures.*.population").reduce((a, b) => a + b, 0);
981
+ },
982
+ // No loop context — omission expands everything (same as [])
983
+ get grandTotal() {
984
+ return this.$getAll("regions.*.total").reduce((a, b) => a + b, 0);
985
+ }
986
+ };
987
+ ```
988
+
989
+ Context levels deeper than the path needs are dropped (a `[$1, $2]` context narrows a one-wildcard path by `[$1]`). But if the path shares **no** wildcard level with a context that does hold loop indexes — say `$getAll("users.*.name")` inside a `regions.*` getter — `$getAll` **throws** instead of silently reading every user: the context indexes belong to a different list, and neither reusing nor ignoring them is what the author meant. Pass indexes explicitly there (`[]` for every match).
990
+
991
+ #### `$setAll` — Update Every Array Element In Place
992
+
993
+ `$setAll` is the write-side counterpart of `$getAll`: it writes to every address a wildcard path matches. The point is not brevity but **keeping the array itself**. Rebuilding it (`this.users = this.users.map(...)`) throws away the list indexes, the per-row getter caches, and the render diff; `$setAll` decomposes into in-place per-row writes instead, so the list identity survives.
994
+
995
+ ```javascript
996
+ export default {
997
+ users: [{ selected: false }, { selected: false }],
998
+
999
+ toggleAll(e) {
1000
+ this.$setAll("users.*.selected", [], e.target.checked); // broadcast
1001
+ },
1002
+ invertAll() {
1003
+ this.$setAll("users.*.selected", [], cur => !cur); // mapper
1004
+ },
1005
+ rankTopThree() {
1006
+ // `undefined` skips that address — "leave this row alone"
1007
+ this.$setAll("users.*.score", [], (cur, i) => i < 3 ? cur * 2 : undefined);
1008
+ }
1009
+ };
1010
+ ```
1011
+
1012
+ Three forms, and the third one has to be asked for explicitly:
1013
+
1014
+ | Third argument | Meaning |
1015
+ |---|---|
1016
+ | a function | **mapper** — called as `(current, ...indexes)` per matched address |
1017
+ | anything else | **broadcast** — the same value is written everywhere, arrays included |
1018
+ | an array **plus** `{ spread: true }` | **spread** — one entry handed to each matched address, in match order |
1019
+
1020
+ Arrays broadcast by default because the target property may itself be array-valued — `$setAll("users.*.tags", [], ["admin"])` would otherwise be ambiguous. Opting into `{ spread: true }` removes the guesswork, and a length that does not equal the match count throws rather than silently misaligning.
1021
+
1022
+ `indexes` works exactly as in `$getAll` — a **prefix**, where missing levels mean "expand all of them" — but it is **required**. Writes get no implicit loop context, so inside a `for` template `this.$setAll("users.*.selected", [], true)` still means *every* user, never the current row.
1023
+
1024
+ ```javascript
1025
+ this.$setAll("matrix.*.*", [0], 0); // row 0 only, every column
1026
+ this.$setAll("users.*", [], rows, { spread: true }); // replace each row, keep the array
1027
+ ```
1028
+
1029
+ `undefined` is never written — it means "skip this address" in all three forms, which keeps a mapper that forgets to `return` from wiping every row. Use `null` to clear. The return value is the number of addresses actually written.
1030
+
1031
+ One thing `$setAll` is not: a shortcut for the dependency walk. Rendering still coalesces into a single batch, but each write is enqueued individually, so the cost matches the hand-written loop it replaces. What it buys you is the preserved list, not fewer cycles.
1032
+
941
1033
  #### `$resolve` — Access by Explicit Index
942
1034
 
943
1035
  `$resolve` reads or writes a value at a specific wildcard index:
@@ -1632,6 +1724,48 @@ Key rules:
1632
1724
 
1633
1725
  See [docs/streams.md](docs/streams.md) for the full contract — lifecycle and ownership, restart semantics, flush granularity, and the out-of-scope list.
1634
1726
 
1727
+ ## Demand roots — what makes a getter run
1728
+
1729
+ Path getters are **lazy**. One that nobody reads is never evaluated. So "does this getter run?" is not answerable from the getter itself — it depends on **where the demand comes from**.
1730
+
1731
+ There are exactly **three** demand roots:
1732
+
1733
+ | Root | Lives in | Depends on rendering |
1734
+ |---|---|---|
1735
+ | **A live DOM binding** | `data-wcs` / mustache / comment bindings | **Yes** — remove the element and the demand goes with it |
1736
+ | **A `$watch` declaration** | the state | No (headless) |
1737
+ | **A `$streams` `args` function** | the state | No (evaluated on start and on every restart) |
1738
+
1739
+ **`$updatedCallback` is not a root.** It reports what the bindings did; it does not create demand.
1740
+
1741
+ ### Rendering can change program semantics
1742
+
1743
+ Because the first root lives in the DOM, **an element you think of as display-only can be the actual subscription**. This one was hit for real, in [`examples/state-intersect-scroll`](../../examples/state-intersect-scroll):
1744
+
1745
+ ```html
1746
+ <!-- Meant as display. It was the only demand root. -->
1747
+ <b data-wcs="textContent: $streamStatus.pageResult"></b>
1748
+ ```
1749
+
1750
+ ```javascript
1751
+ // $updatedCallback is binding-driven — delete that <b> and the path stops
1752
+ // appearing in `paths`, so the feed silently stops committing.
1753
+ $updatedCallback(paths) {
1754
+ if (!paths.includes("$streamStatus.pageResult")) return;
1755
+ this.items = this.items.concat(this.pageResult.items);
1756
+ }
1757
+ ```
1758
+
1759
+ **The rule:** logic that must not depend on what is rendered belongs on a `$watch` (or a `$streams` `args`). Keep `$updatedCallback` for "follow what was drawn".
1760
+
1761
+ That example now uses `$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`**.
1762
+
1763
+ ### The limitation that remains
1764
+
1765
+ 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.
1766
+
1767
+ 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.
1768
+
1635
1769
  ## Watch (`$watch`)
1636
1770
 
1637
1771
  `$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.)
@@ -1683,6 +1817,8 @@ Firing order is defined in three layers, and only the middle one is yours to ste
1683
1817
  | Between handlers | declaration order in `$watch` | **reorder the declarations** |
1684
1818
  | Between rows of one path | ascending `indexes` | fixed |
1685
1819
 
1820
+ **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 `$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 `$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.
1821
+
1686
1822
  Key rules:
1687
1823
 
1688
1824
  - **Only its own state** — a path may not carry `@stateName`; watching another state element is rejected at declaration time.
@@ -1944,6 +2080,101 @@ All hooks except `$disconnectedCallback` support `async` — you can use `async/
1944
2080
  - `$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. Can be `async`, but the return value is not awaited
1945
2081
  - In Web Components, define `async $stateReadyCallback(stateProp)` to receive a hook when the bound state becomes available via `bind-component`
1946
2082
 
2083
+ ## Transition animations
2084
+
2085
+ Enter animations need nothing from this package — a new `for` row and a mounting `if` branch are newly inserted elements, so plain CSS covers them:
2086
+
2087
+ ```css
2088
+ li {
2089
+ transition: opacity 0.2s, transform 0.2s;
2090
+ @starting-style { opacity: 0; transform: translateY(-4px); }
2091
+ }
2092
+ ```
2093
+
2094
+ **Leaving** and **moving** cannot be reached that way: removed rows are detached synchronously, and a reorder has no intermediate state. Adding [`@wcstack/view-transition`](https://github.com/wcstack/wcstack/tree/main/packages/view-transition) makes the drain apply its DOM changes inside a View Transition, where the browser snapshots the old state for you:
2095
+
2096
+ ```html
2097
+ <script type="module" src="https://esm.run/@wcstack/view-transition/auto"></script>
2098
+ <wcs-view-transition naming="auto"></wcs-view-transition>
2099
+ ```
2100
+
2101
+ Two consequences to know while that tag accepts the `state` participant:
2102
+
2103
+ - 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.
2104
+ - Because `$watch` and the `$streams` restart stay on the original microtask, they now run **before** `$updatedCallback` instead of after it.
2105
+
2106
+ 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.
2107
+
2108
+ ## Diagnostics and failure handling
2109
+
2110
+ ### Wiring to a path that does not exist is reported
2111
+
2112
+ When a wired path provably does not resolve against the state, you get one warning at binding time (at declaration time for `$watch`). The diagnostic codes are shared by the console, `@wcstack/lint`, and the VS Code extension:
2113
+
2114
+ ```
2115
+ [@wcstack/state] [wcs/binding-path-missing] Bound path "user.nmae" does not resolve on state "default":
2116
+ "nmae" is not declared. Did you mean "name"? Updates to this path will be silently
2117
+ dropped. Validate statically: npx @wcstack/lint <file>.
2118
+ ```
2119
+
2120
+ | Situation | Behavior |
2121
+ |---|---|
2122
+ | Typo in a nested path (`user.nmae`) | `console.warn` (`wcs/binding-path-missing`). Updates still never arrive — you fix it |
2123
+ | Typo in a top-level path (`cout`) | Throws on read, with the same wording and did-you-mean |
2124
+ | Typo in a `$watch` key | `console.warn` (`wcs/watch-path-missing`), reported even for a single segment |
2125
+
2126
+ The check **under-approximates**: it stays silent for anything it cannot decide statically, because a false alarm costs more than a missed one. None of these warn:
2127
+
2128
+ - A `null` / `undefined` parent (the "seed as `null`, assign later" shape)
2129
+ - Row fields of a list that starts empty (the row shape is unknown)
2130
+ - Sub-properties of an intermediate getter's return value
2131
+ - Mapped `bind-component` child scopes (the parent owns the path)
2132
+ - Reserved `$` namespaces (`$command.*` and friends)
2133
+
2134
+ So **no warning is not a proof of correctness.** For exhaustive checking, run `npx @wcstack/lint <file>`.
2135
+
2136
+ ### Index arity, wildcard rank, and getter cycles are checked too
2137
+
2138
+ Anything that follows mechanically from the path string is reported at runtime and by the linter under the same diagnostic code.
2139
+
2140
+ | Diagnostic | What it checks | Fix |
2141
+ |---|---|---|
2142
+ | `wcs/index-arity` | `$resolve(path, indexes)` must match the `*` count **exactly**; `$getAll(path, indexes)` / `$setAll(path, indexes, …)` have it as an **upper bound** (fewer is a legitimate prefix meaning "expand the rest") | Match the count |
2143
+ | `wcs/wildcard-rank` | The path's `*` count (and the N in `$N`) must not exceed the enclosing `for` nesting | Add a `for`, or name the row with `$resolve(path, indexes)` |
2144
+ | `wcs/getter-cycle` | Path getters must not form a dependency cycle | Break the cycle |
2145
+
2146
+ Previously **extra indexes were silently discarded** by both APIs, so a mixed-up call returned a plausible-looking wrong value. Both now throw:
2147
+
2148
+ ```javascript
2149
+ // ❌ Only one "*" in the path, two indexes given — used to return items[0]'s value
2150
+ this.$resolve("items.*.price", [row, col]);
2151
+
2152
+ // ✅ Two levels, two indexes
2153
+ this.$resolve("matrix.*.*", [row, col]);
2154
+ // ✅ Fewer is fine for $getAll — it means "expand the remaining levels"
2155
+ this.$getAll("matrix.*.*", [row]);
2156
+ ```
2157
+
2158
+ ### One failing binding is confined to that binding
2159
+
2160
+ 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`):
2161
+
2162
+ ```
2163
+ [@wcstack/state] binding "text: items.*.label" failed to apply; the rest of this batch continues.
2164
+ ```
2165
+
2166
+ 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.
2167
+
2168
+ ### Values and the DOM are never rolled back
2169
+
2170
+ Every failure mode reports and continues; nothing already applied is reverted:
2171
+
2172
+ | Mechanism | Limit | On exceeding |
2173
+ |---|---|---|
2174
+ | Propagation hops | 32 | Quarantine the transaction's remaining records |
2175
+ | `$watch` write chain | 32 | Skip watch firing for that batch |
2176
+ | Binding apply failure | — | Skip that one binding |
2177
+
1947
2178
  ## Configuration
1948
2179
 
1949
2180
  Pass a partial configuration object to `bootstrapState()`:
@@ -1965,13 +2196,43 @@ All options with defaults:
1965
2196
  |---|---|---|
1966
2197
  | `bindAttributeName` | `'data-wcs'` | Binding attribute name |
1967
2198
  | `tagNames.state` | `'wcs-state'` | State element tag name |
1968
- | `locale` | `'en'` | Default locale for filters |
2199
+ | `locale` | `<html lang>`, else `'en'` | Locale for the locale-dependent filters (`locale` / `date` / `time` / `datetime`) — see [Locale](#locale) |
1969
2200
  | `debug` | `false` | Debug mode |
1970
2201
  | `enableMustache` | `true` | Enable `{{ }}` syntax |
1971
2202
  | `enableDirectionalInitialSync` | `true` | Direction-aware binding authority (`#init=` / `#sync=` binding modifiers) — see [Binding Authority](#binding-authority-init--sync). Default on; set `false` to opt out |
1972
2203
  | `enablePropagationContext` | `true` | Causal propagation tracking across bindings (echo/diamond loop prevention). Default on; set `false` to opt out |
1973
2204
  | `enableContractAnalyzer` | `false` | Opt-in dev-time contract analyzer (exposes `analyzeContract`) |
1974
2205
 
2206
+ ### Locale
2207
+
2208
+ Four filters format by locale — `locale`, `date`, `time`, `datetime`. They read
2209
+ `config.locale`, which **defaults to `<html lang>`**:
2210
+
2211
+ ```html
2212
+ <html lang="ja-JP">
2213
+ <script type="module" src="https://esm.run/@wcstack/state/auto"></script>
2214
+ ```
2215
+
2216
+ Nothing else is needed; `<html lang>` is the standard place to record a page's
2217
+ language, and making it the default keeps one source of truth. It also means the
2218
+ CDN one-liner can set the locale at all — `auto` calls `bootstrapState()` with no
2219
+ arguments, so before this there was no way in. An explicit
2220
+ `bootstrapState({ locale })` still wins, and an invalid BCP-47 tag is reported
2221
+ and ignored rather than left to throw inside `Intl`.
2222
+
2223
+ **Changing `config.locale` later does not re-render anything.** It is a global
2224
+ setting, not state, so it is not part of the dependency graph. The filters do
2225
+ read it on every application rather than capturing it when the binding is built,
2226
+ which means a binding that re-renders for its own reasons will pick up the new
2227
+ value — enough to recover from a mis-ordered startup, not enough to switch a
2228
+ page's language. Set the language before the page renders: writing `<html lang>`
2229
+ in the markup, or from a synchronous `<head>` script, does that structurally.
2230
+
2231
+ Per-call overrides stay available and are fixed at bind time, since they are part
2232
+ of the binding expression: `price|locale(fr-FR)`. For a page that switches
2233
+ language without reloading, see [docs/i18n-design.md](../../docs/i18n-design.md) —
2234
+ the short answer is that translations belong on a path, not in a filter.
2235
+
1975
2236
  > These three are **architecture-hardening** features; their normative reference is
1976
2237
  > `docs/architecture-hardening/`. `enablePropagationContext` defaults **on** — its
1977
2238
  > write-path cost is near-zero for one-way bindings (only echo-capable two-way