@wcstack/state 1.25.0 → 1.27.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
@@ -95,7 +95,7 @@ That's it. No build, no bootstrap code, no framework.
95
95
  - **Declarative data binding** — `data-wcs` attribute for property / text / event / structural binding
96
96
  - **Reactive Proxy** — ES Proxy-based automatic DOM updates with dependency tracking
97
97
  - **Structural directives** — `for`, `if` / `elseif` / `else` via `<template>` elements
98
- - **Built-in filters** — 40 filters for formatting, comparison, arithmetic, date, and more
98
+ - **Built-in filters** — 46 filters for formatting, comparison, arithmetic, date, and more
99
99
  - **Two-way binding** — automatic for `<input>`, `<select>`, `<textarea>`
100
100
  - **Web Component binding** — bidirectional state binding with Shadow DOM components
101
101
  - **Command tokens** — invoke methods on wc-bindable custom elements from state via a pub/sub channel (`command.<method>: tokenName`)
@@ -220,6 +220,8 @@ That's it. No build, no bootstrap code, no framework.
220
220
 
221
221
  Resolution order: `state` → `src` (.json / .js) → `json` → inner `<script>` → wait for `setInitialState()`.
222
222
 
223
+ > **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).
224
+
223
225
  ### Named State
224
226
 
225
227
  Multiple state elements can coexist with the `name` attribute. Bindings reference them with `@name`:
@@ -441,7 +443,7 @@ Internally converted to comment-based bindings (`<!--@@:expression-->`).
441
443
 
442
444
  ### Spread Binding (`...`)
443
445
 
444
- For custom elements that declare the [`wc-bindable` protocol](#wcbindable-protocol), `...: target` wires all of the element's **properties + inputs** to a single state object in one line:
446
+ For custom elements that declare the [`wc-bindable` protocol](#bindables--commands-and-the-wc-bindable-protocol), `...: target` wires all of the element's **properties + inputs** to a single state object in one line:
445
447
 
446
448
  ```html
447
449
  <wcs-fetch data-wcs="...: usersFetch"></wcs-fetch>
@@ -507,7 +509,42 @@ Structural directives use `<template>` elements:
507
509
  </template>
508
510
  ```
509
511
 
510
- The `for:` directive uses a **value-based diff algorithm** — each array element's value itself serves as the identity key. There is no need for an explicit `key` attribute (like React's `key` or Vue's `:key`). When the array is reassigned, the differ matches old and new elements by value, reusing existing DOM nodes for unchanged items and efficiently adding, removing, or reordering the rest.
512
+ The `for:` directive uses a **value-based diff algorithm** — each array element's value itself serves as the identity key. When the array is reassigned, the differ matches old and new elements by value, reusing existing DOM nodes for unchanged items and efficiently adding, removing, or reordering the rest.
513
+
514
+ This means **no explicit `key` attribute is needed for adding, removing, or reordering rows** (like React's `key` or Vue's `:key`) — as long as row objects keep their references. Non-destructive array methods (`toSorted`, `toReversed`, `filter`, `with`, `toSpliced`) all preserve element references, so sorting and filtering are keyed by construction, and the whole class of "wrong key" bugs cannot occur.
515
+
516
+ The exception is data that arrives as **freshly created objects** — `fetch(...).json()`, `JSON.parse` from storage, a WebSocket/SSE full snapshot, or a Worker `postMessage`. Those rows never match by reference, so every row is torn down and rebuilt. See [`$listKeys`](#listkeys--identity-for-refetched-rows) below.
517
+
518
+ #### `$listKeys` — identity for refetched rows
519
+
520
+ When rows carry DOM state the bindings do not own — focus, an in-flight IME composition, `<details>` open state, inner scroll position, `<canvas>` contents, `<video>` playback — rebuilding the rows loses it. Declare a key so the framework can recognize rows across a refresh:
521
+
522
+ ```js
523
+ {
524
+ items: [],
525
+ $listKeys: {
526
+ "items": "id", // field name
527
+ "items.*.children": (row) => row.uid, // or a function, for composite keys
528
+ },
529
+ }
530
+ ```
531
+
532
+ With a key declared, assigning a new array **keeps the existing row objects** and writes only the fields that actually changed into them. The row's DOM is reused rather than rebuilt:
533
+
534
+ ```js
535
+ // Every row object is new, but rows are matched by id — DOM, focus and
536
+ // <details> state survive, and only the fields that differ are written.
537
+ this.items = await (await fetch("/api/items")).json();
538
+ ```
539
+
540
+ Notes:
541
+
542
+ - **Opt-in and per-path.** Lists without a declaration behave exactly as before, at no cost.
543
+ - **Nesting is opt-in too.** Only declared paths are matched by key; undeclared nested arrays are replaced by reference as usual. This lets you adopt it one list at a time.
544
+ - **A no-op refresh is free.** If nothing changed, no field is written and no DOM work happens at all.
545
+ - **Rows must be plain objects**, and keys must be present and unique. Duplicate keys, missing keys, and class instances raise an error immediately rather than degrading silently.
546
+ - **Fields dropped from a row are cleared with `null`**, which is this package's vocabulary for an explicit clear (`undefined` means "the state has no opinion" and skips the write).
547
+ - The stored array is rebuilt from matched row objects, so `this.items !== theArrayYouAssigned` afterwards.
511
548
 
512
549
  #### Dot Shorthand
513
550
 
@@ -949,7 +986,7 @@ export default {
949
986
 
950
987
  ## Filters
951
988
 
952
- 40 built-in filters are available for both input (DOM → state) and output (state → DOM) directions.
989
+ 46 built-in filters are available for both input (DOM → state) and output (state → DOM) directions.
953
990
 
954
991
  ### Comparison
955
992
 
@@ -972,6 +1009,8 @@ export default {
972
1009
  | `mul(n)` | Multiply | `price\|mul(1.1)` |
973
1010
  | `div(n)` | Divide | `total\|div(100)` |
974
1011
  | `mod(n)` | Modulo | `index\|mod(2)` |
1012
+ | `abs` | Absolute value | `delta\|abs` |
1013
+ | `clamp(min, max)` | Constrain to a range | `ratio\|clamp(0,100)` |
975
1014
 
976
1015
  ### Number Formatting
977
1016
 
@@ -983,6 +1022,7 @@ export default {
983
1022
  | `ceil(n?)` | Ceiling | `value\|ceil` |
984
1023
  | `locale(loc?)` | Locale number format | `count\|locale` / `count\|locale(ja-JP)` |
985
1024
  | `percent(n?)` | Percentage format | `ratio\|percent(1)` |
1025
+ | `unit(u)` | Append a unit (any suffix) | `width\|unit(px)` → `"40px"` |
986
1026
 
987
1027
  ### String
988
1028
 
@@ -997,6 +1037,8 @@ export default {
997
1037
  | `pad(n, char?)` | Pad start | `id\|pad(5,0)` → `"00001"` |
998
1038
  | `rep(n)` | Repeat | `text\|rep(3)` |
999
1039
  | `rev` | Reverse | `text\|rev` |
1040
+ | `truncate(n, suffix?)` | Shorten and append an ellipsis | `title\|truncate(20)` |
1041
+ | `join(sep?)` | Join an array (default `", "`) | `tags\|join` / `tags\|join(/)` |
1000
1042
 
1001
1043
  ### Type Conversion
1002
1044
 
@@ -1017,6 +1059,7 @@ export default {
1017
1059
  | `time(loc?)` | Time format | `timestamp\|time` |
1018
1060
  | `datetime(loc?)` | Date + Time | `timestamp\|datetime(en-US)` |
1019
1061
  | `ymd(sep?)` | YYYY-MM-DD | `timestamp\|ymd` / `timestamp\|ymd(/)` |
1062
+ | `hms(sep?)` | HH:MM:SS | `timestamp\|hms` / `timestamp\|hms(-)` |
1020
1063
 
1021
1064
  ### Boolean / Default
1022
1065
 
@@ -1089,6 +1132,18 @@ customElements.define("my-light-component", MyLightComponent);
1089
1132
  - Bindings must explicitly reference the state name with `@my-light`
1090
1133
  - `<wcs-state>` must be a direct child of the component element
1091
1134
 
1135
+ - Binding from the host (`<my-light-component data-wcs="state.message: user.name">`) works just as it
1136
+ does for Shadow DOM. The component's subtree is treated as an **independent binding scope** and is
1137
+ wired once the component's own state has registered its name
1138
+
1139
+ > **Note**: Light DOM shares its namespace with the parent scope, so **two instances carrying the same
1140
+ > `name` cannot live in one scope**. Use Shadow DOM for shapes that place a component on every row of
1141
+ > a list.
1142
+ >
1143
+ > Also, `State.getBindingsReady(root)` does not cover the component's scope — the same as the Shadow
1144
+ > DOM form, where the child lives in a different rootNode. Await the component's own `<wcs-state>`
1145
+ > initialization when you need to wait for its contents to render.
1146
+
1092
1147
  ### Host Usage
1093
1148
 
1094
1149
  ```html
@@ -1157,6 +1212,50 @@ customElements.define("my-component", MyComponent);
1157
1212
  </template>
1158
1213
  ```
1159
1214
 
1215
+ ### Rendering a List Inside the Component
1216
+
1217
+ An array can be bound into a component and iterated with `for:` **inside** it. The outer state
1218
+ stays the source of truth; row additions, removals, reordering and row-field writes flow both ways.
1219
+
1220
+ ```html
1221
+ <!-- Host -->
1222
+ <wcs-state json='{"rows":[{"name":"Alice"},{"name":"Bob"}]}'></wcs-state>
1223
+ <my-list data-wcs="state.items: rows"></my-list>
1224
+ ```
1225
+
1226
+ ```javascript
1227
+ // Component (Shadow DOM)
1228
+ this.shadowRoot.innerHTML = `
1229
+ <wcs-state bind-component="state"></wcs-state>
1230
+ <ul>
1231
+ <template data-wcs="for: items">
1232
+ <li data-wcs="textContent: .name"></li>
1233
+ </template>
1234
+ </ul>
1235
+ `;
1236
+ ```
1237
+
1238
+ - Replacing `rows` or writing a single row field (`rows.0.name`) both reach the rows inside the component
1239
+ - Writing `items.*.name` from inside the component reaches the host's `rows`
1240
+
1241
+ #### Nesting and stacking scopes
1242
+
1243
+ A component that sits inside a host `for:` *and* runs its own `for:` over the array it was handed
1244
+ is supported. The framework keeps the outer row and the inner row related:
1245
+
1246
+ ```html
1247
+ <template data-wcs="for: groups">
1248
+ <my-list data-wcs="state.items: groups.*.children"></my-list>
1249
+ </template>
1250
+ ```
1251
+
1252
+ Components can also be placed inside components, **stacking scopes**. An intermediate component
1253
+ that only passes the array through — running no `for:` of its own — still lets a row-field write
1254
+ from the owning scope reach the rows at the bottom.
1255
+
1256
+ A component's author never has to know how deeply it is placed. `$1`, event-handler indexes,
1257
+ `$updatedCallback` and `$getAll` all report positions **within the component's own scope**.
1258
+
1160
1259
  ## Command Token (Method Binding)
1161
1260
 
1162
1261
  Property binding (`state.message: user.name`) covers data flowing into a component, but it does not cover **invoking a method on a component from state** — `<wcs-fetch>.fetch()`, `<wcs-dialog>.open()`, and so on. **Command tokens** fill that gap with a typed pub/sub channel:
@@ -1456,6 +1555,8 @@ Event tokens share the same `Token` pub/sub primitive as command tokens — `nam
1456
1555
 
1457
1556
  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.
1458
1557
 
1558
+ `$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.
1559
+
1459
1560
  ```html
1460
1561
  <wcs-state>
1461
1562
  <script type="module">
@@ -1531,6 +1632,68 @@ Key rules:
1531
1632
 
1532
1633
  See [docs/streams.md](docs/streams.md) for the full contract — lifecycle and ownership, restart semantics, flush granularity, and the out-of-scope list.
1533
1634
 
1635
+ ## Watch (`$watch`)
1636
+
1637
+ `$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.)
1638
+
1639
+ ```html
1640
+ <wcs-state>
1641
+ <script type="module">
1642
+ export default {
1643
+ isLoading: false,
1644
+ items: [],
1645
+ startedAt: 0,
1646
+
1647
+ $watch: {
1648
+ // rising-edge detection: you compare cur/prev yourself
1649
+ isLoading(cur, prev) {
1650
+ if (cur === true && prev === false) { this.startedAt = Date.now(); }
1651
+ },
1652
+
1653
+ // wildcard paths fire once per changed row
1654
+ // (needs the list rendered with `for`, or `$listKeys` declared — see below)
1655
+ "items.*.price"(cur, prev, index) {
1656
+ this.lastPriceChange = `#${index}: ${prev} → ${cur}`;
1657
+ },
1658
+ },
1659
+ };
1660
+ </script>
1661
+ </wcs-state>
1662
+ ```
1663
+
1664
+ The handler runs with `this` bound to a **writable** state proxy, so it can write back; those writes land in the next update batch. The return value is ignored and never awaited.
1665
+
1666
+ | Argument | Contract |
1667
+ |---|---|
1668
+ | `cur` | The value at drain time (the settled value for the batch) |
1669
+ | `prev` | The value at the **start of the batch** (first-write-wins). Meaningful **for scalars only** — see below |
1670
+ | `...indexes` | Only for wildcard paths: this scope's own loop indexes, same convention as `$1`, `$2` |
1671
+
1672
+ **`prev` is scalar-only.** It reuses the old value the same-value guard already reads, so watch costs no extra read — and it is `undefined` for reference types (an in-place mutation would give you the same reference anyway), for `$postUpdate`, and when `config.sameValueGuard` is off.
1673
+
1674
+ **Watch adds no firing condition of its own.** It fires for whatever landed in the update batch. That falls out well: an equal primitive write is already dropped before it is enqueued (so you effectively get change-only firing), while an occurrence write — a `semantics: "event"` property — is deliberately *not* dropped, and still fires with `cur === prev`. If you need edge detection, compare `cur` and `prev` in the handler.
1675
+
1676
+ **Watching a getter makes it eager.** A computed getter is normally lazy, and its dependencies are only recorded when it is evaluated — so an unrendered getter would never fire at all. Declaring one in `$watch` evaluates it once at connect and again at the end of every batch that touches its dependencies. Its `prev` is the previous evaluation. Watch a heavy computed and you pay that evaluation on every batch; exceptions inside it surface through the watch instead of staying dormant. Wildcard getters (`items.*.tax`) are **not** made eager — priming one would sweep the whole list — so that form fires only when it is also bound to the DOM, and its `prev` is always `undefined` (no per-row evaluation is remembered).
1677
+
1678
+ Firing order is defined in three layers, and only the middle one is yours to steer:
1679
+
1680
+ | Layer | Order | Your control |
1681
+ |---|---|---|
1682
+ | Mechanisms | `$updatedCallback` → `$watch` → `$streams` restart | fixed |
1683
+ | Between handlers | declaration order in `$watch` | **reorder the declarations** |
1684
+ | Between rows of one path | ascending `indexes` | fixed |
1685
+
1686
+ Key rules:
1687
+
1688
+ - **Only its own state** — a path may not carry `@stateName`; watching another state element is rejected at declaration time.
1689
+ - **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.
1690
+ - **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.
1691
+ - **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.
1692
+ - **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.
1693
+ - **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.
1694
+ - **Not available on a mapped `bind-component` child** — its state is wrapped in a proxy that blanks out every `$`-prefixed property, so the declaration never arrives. This applies to `$streams` too. A plain (unmapped) child can declare it.
1695
+ - **SSR does not run watches** — handler side effects would otherwise execute on both server and client.
1696
+
1534
1697
  ## Inputs and Attribute Mirror
1535
1698
 
1536
1699
  `wcBindable.inputs` declares one-way property inputs (state → element). When an entry sets `attribute`, the framework writes the value to that HTML attribute every time it writes the property, so `attributeChangedCallback`, CSS attribute selectors, and DevTools all stay in sync with the property value.
@@ -1577,6 +1740,24 @@ Notes:
1577
1740
  - Mirror is best-effort: a `setAttribute` failure is swallowed (with a `debug` warning) and does not block the property write
1578
1741
  - Native HTML elements ignore `inputs` entirely — the mirror only activates for custom elements that expose `static wcBindable`
1579
1742
 
1743
+ ## Choosing a Component Mechanism
1744
+
1745
+ Two mechanisms give a custom element its own state, and they are **mutually exclusive** — pick one per component:
1746
+
1747
+ | | [DCC](#declarative-custom-components-dcc) | [`bind-component`](#web-component-binding) |
1748
+ |---|---|---|
1749
+ | How the element is defined | HTML only (`data-wc-definition` + Declarative Shadow DOM) | A JavaScript `class extends HTMLElement` you write |
1750
+ | Where the state lives | An inline `<script type="module">` in the template, loaded per instance | A property on the component instance (`this.state`) |
1751
+ | `static wcBindable` | Generated from `$bindables` / `$commands` | **None** — the element is not a wc-bindable producer |
1752
+ | Parent binds a value | `count: parentCount` (two-way, change events) | `state.msg: user.name` (path mapping) |
1753
+ | Parent invokes a method | `command.bumpBy: $command.bump` | Not available — expose it on the class and call it yourself |
1754
+ | Spread (`...: obj`) | Available | Not available (requires a `wcBindable` declaration) |
1755
+ | Component reads/writes its own state | `this.count` on the element | `this.state.msg` |
1756
+
1757
+ The rule of thumb: **if the component has no JavaScript class, use DCC; if you are already writing a class, use `bind-component`.** Combining them raises — a `<wcs-state bind-component>` inside a `data-wc-definition` host is a configuration error, because DCC state belongs to the template and is loaded per instance.
1758
+
1759
+ `bind-component` components deliberately stay outside the wc-bindable protocol: they are wired by **path**, not by a declared property surface. That is why spread and command tokens, both of which need a `wcBindable` declaration, do not apply to them.
1760
+
1580
1761
  ## Declarative Custom Components (DCC)
1581
1762
 
1582
1763
  Define custom elements **entirely in HTML** — no JavaScript class definition needed. Using `data-wc-definition` and Declarative Shadow DOM (`<template shadowrootmode>`), you can declare reusable components with reactive state inline.
@@ -1621,23 +1802,55 @@ The definition element is hidden; each instance clones the template into its own
1621
1802
  [data-wc-definition] { display: none; }
1622
1803
  ```
1623
1804
 
1624
- ### `$bindables` and wc-bindable Protocol
1805
+ ### `$bindables` / `$commands` and the wc-bindable Protocol
1625
1806
 
1626
- The `$bindables` array declares which state properties are exposed as component properties with change events, following the [wc-bindable protocol](https://github.com/nicenemo/nicenemo/blob/main/docs/wc-bindable-protocol.md):
1807
+ `$bindables` declares which state **properties** are exposed as component properties with change events. `$commands` declares which state **methods** are exposed as invocable commands. Together they build the [wc-bindable protocol](https://github.com/wc-bindable-protocol/wc-bindable-protocol/blob/main/README.md) declaration:
1627
1808
 
1628
1809
  ```javascript
1629
1810
  export default {
1630
1811
  count: 0,
1631
- increment() { this.count++; },
1632
- $bindables: ["count"]
1812
+ bumpBy(step) { this.count += step; },
1813
+ $bindables: ["count"],
1814
+ $commands: ["bumpBy"]
1633
1815
  };
1634
1816
  ```
1635
1817
 
1636
1818
  This generates:
1637
1819
 
1638
- - `static wcBindable` on the class — protocol metadata for framework adapters. Each `$bindables` member is declared in both `properties` and `inputs` (two-way), so parent-state → DCC writes keep working under directional initial sync — see [Binding Authority](#binding-authority-init--sync)
1639
- - Getter/setter on the prototype — reads/writes go through the reactive proxy
1640
- - `CustomEvent` dispatch — `my-counter:count-changed` fires on every mutation
1820
+ - `static wcBindable` on the class — protocol metadata for framework adapters. Each `$bindables` member is declared in both `properties` and `inputs` (two-way), so parent-state → DCC writes keep working under directional initial sync — see [Binding Authority](#binding-authority-init--sync). Each `$commands` member becomes a `commands` entry
1821
+ - Getter/setter on the prototype for `$bindables`, a method for `$commands` both go through the reactive proxy
1822
+ - `CustomEvent` dispatch — `my-counter:count-changed` fires on every mutation of a `$bindables` member
1823
+
1824
+ `commands` entries are always declared `async: true`. A DCC method chains on the inner `<wcs-state>`'s initialization, so it returns a Promise whether or not the state method itself was written `async`.
1825
+
1826
+ Both declarations are validated when the component is defined, with the same strictness as `$commandTokens`. Each of the following raises:
1827
+
1828
+ - not an array
1829
+ - an entry that is not a non-empty string
1830
+ - an entry starting with `$` (internal properties are never exposed on the component prototype)
1831
+ - 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
1832
+ - 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)
1833
+ - a method listed in `$bindables`, or a value property listed in `$commands`
1834
+
1835
+ ### Driving a DCC Method
1836
+
1837
+ A `$commands` member can be invoked from the parent state with a [command token](#command-token-method-binding), exactly like an I/O node:
1838
+
1839
+ ```html
1840
+ <wcs-state>
1841
+ <script type="module">
1842
+ export default {
1843
+ $commandTokens: ["bump"],
1844
+ fire() { this.$command.bump.emit(3); }
1845
+ };
1846
+ </script>
1847
+ </wcs-state>
1848
+
1849
+ <button data-wcs="onclick: fire">bump</button>
1850
+ <my-counter data-wcs="command.bumpBy: $command.bump"></my-counter>
1851
+ ```
1852
+
1853
+ Positional arguments pass through verbatim, so `emit(3)` calls `bumpBy(3)` on the component's state.
1641
1854
 
1642
1855
  ### Binding to DCC Properties
1643
1856
 
@@ -1673,6 +1886,7 @@ Properties prefixed with `$` are internal and not exposed on the component proto
1673
1886
  | Property | Purpose |
1674
1887
  |----------|---------|
1675
1888
  | `$bindables` | Declares observable properties |
1889
+ | `$commands` | Declares invocable methods |
1676
1890
  | `$connectedCallback` | Lifecycle hook (runs on each instance) |
1677
1891
  | `$disconnectedCallback` | Cleanup hook |
1678
1892
  | `$updatedCallback` | Called after state mutations |
@@ -1720,14 +1934,14 @@ State objects can define `$connectedCallback`, `$disconnectedCallback`, and `$up
1720
1934
  |---|---|---|
1721
1935
  | `$connectedCallback` | After state initialization on first connect; on every reconnect thereafter | Yes (awaited) |
1722
1936
  | `$disconnectedCallback` | When the element is removed from the DOM | No (sync only) |
1723
- | `$updatedCallback(paths, indexesListByPath)` | After state updates are applied | Yes (not awaited) |
1937
+ | `$updatedCallback(paths, indexesListByPath)` | After updates are applied to live bindings | Yes (not awaited) |
1724
1938
 
1725
1939
  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.
1726
1940
 
1727
1941
  - `this` inside hooks is the state proxy with full read/write access
1728
1942
  - `$connectedCallback` is called **every time** the element is connected (including re-insertion after removal), making it suitable for setup that should be re-established
1729
1943
  - `$disconnectedCallback` is called synchronously — use it for cleanup such as clearing timers, removing event listeners, or releasing resources
1730
- - `$updatedCallback(paths, indexesListByPath)` receives the updated path list. For wildcard updates, `indexesListByPath` contains the updated index sets. Can be `async`, but the return value is not awaited
1944
+ - `$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
1731
1945
  - In Web Components, define `async $stateReadyCallback(stateProp)` to receive a hook when the bound state becomes available via `bind-component`
1732
1946
 
1733
1947
  ## Configuration