@wcstack/state 1.24.0 → 1.26.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.ja.md CHANGED
@@ -220,6 +220,8 @@
220
220
 
221
221
  解決順序: `state` → `src` (.json / .js) → `json` → 内包 `<script>` → `setInitialState()` 待機。
222
222
 
223
+ > **Content-Security-Policy 下では:** 5 番(内包 `<script type="module">`)は `blob:` URL 経由で評価されるため `script-src blob:` が必要です。ページの nonce では救えません。厳格な CSP を敷く場合は 4 番(`src="./state.js"`)を使ってください。追加ディレクティブは不要です。詳細は [docs/csp.md](../../docs/csp.md)。
224
+
223
225
  ### 名前付き状態
224
226
 
225
227
  複数の状態要素を `name` 属性で共存できます。バインディングでは `@name` で参照します:
@@ -507,7 +509,42 @@ export default {
507
509
  </template>
508
510
  ```
509
511
 
510
- `for:` ディレクティブは**値ベースの差分アルゴリズム**を使用します。配列の各要素の値そのものが識別キーとして機能するため、React の `key` や Vue の `:key` のような明示的なキー属性は不要です。配列が再代入されると、差分アルゴリズムが新旧の要素を値で照合し、変更のない要素の DOM ノードを再利用しつつ、追加・削除・並び替えを効率的に処理します。
512
+ `for:` ディレクティブは**値ベースの差分アルゴリズム**を使用します。配列の各要素の値そのものが識別キーとして機能します。配列が再代入されると、差分アルゴリズムが新旧の要素を値で照合し、変更のない要素の DOM ノードを再利用しつつ、追加・削除・並び替えを効率的に処理します。
513
+
514
+ つまり、**行オブジェクトの参照さえ保たれていれば、行の追加・削除・並べ替えに明示的なキー属性(React の `key` や Vue の `:key`)は不要です**。非破壊の配列メソッド(`toSorted` / `toReversed` / `filter` / `with` / `toSpliced`)はいずれも要素の参照を保つため、ソートやフィルタは構造的に keyed に動作し、「キーの付け方を間違える」種類のバグが原理的に発生しません。
515
+
516
+ 例外は、**新しく生成されたオブジェクトとして届くデータ**です。`fetch(...).json()`、ストレージからの `JSON.parse`、WebSocket / SSE の全件スナップショット、Worker の `postMessage` などがこれにあたります。これらの行は参照で照合できないため、全行が破棄・再構築されます。次の [`$listKeys`](#listkeys--再取得された行の同一性) を参照してください。
517
+
518
+ #### `$listKeys` — 再取得された行の同一性
519
+
520
+ 行がバインディングの管理外の DOM 状態(フォーカス、IME 変換中の文字列、`<details>` の開閉、行内スクロール位置、`<canvas>` の描画内容、`<video>` の再生位置)を持つ場合、行の再構築でそれらは失われます。キーを宣言すると、リフレッシュをまたいで行を同定できます:
521
+
522
+ ```js
523
+ {
524
+ items: [],
525
+ $listKeys: {
526
+ "items": "id", // フィールド名
527
+ "items.*.children": (row) => row.uid, // 複合キー用の関数も可
528
+ },
529
+ }
530
+ ```
531
+
532
+ キーを宣言すると、新しい配列を代入しても**既存の行オブジェクトが据え置かれ**、実際に変化したフィールドだけがそこへ書き込まれます。行の DOM は再構築されず再利用されます:
533
+
534
+ ```js
535
+ // 行オブジェクトはすべて新しいが id で照合されるため、DOM・フォーカス・
536
+ // <details> の状態は保たれ、差分のあるフィールドだけが書き込まれる
537
+ this.items = await (await fetch("/api/items")).json();
538
+ ```
539
+
540
+ 補足:
541
+
542
+ - **opt-in かつパス単位**です。宣言していないリストは従来どおりの挙動で、追加コストもありません。
543
+ - **ネストも opt-in** です。宣言されたパスだけがキー照合され、未宣言のネスト配列は従来どおり参照置換されます。リスト単位で段階的に導入できます。
544
+ - **無変化のリフレッシュはゼロコスト**です。何も変わっていなければフィールド書き込みも DOM 操作も一切発生しません。
545
+ - **行は plain object** である必要があり、キーは存在かつ一意でなければなりません。キーの重複・欠落・クラスインスタンスは、静かに劣化させず即座にエラーになります。
546
+ - **行から消えたフィールドは `null` でクリア**されます。`null` はこのパッケージにおける明示的なクリアの語彙です(`undefined` は「状態が値を持たない」を意味し、書き込み自体がスキップされます)。
547
+ - 格納される配列は照合済みの行オブジェクトから組み直されるため、代入後は `this.items !== 代入した配列` になります。
511
548
 
512
549
  #### ドット省略記法
513
550
 
@@ -1156,6 +1193,34 @@ customElements.define("my-component", MyComponent);
1156
1193
  </template>
1157
1194
  ```
1158
1195
 
1196
+ ### コンポーネント側でリストを描画する
1197
+
1198
+ 配列をコンポーネントに束ね、`for:` をコンポーネントの内側で回すこともできます。
1199
+ 値の正本は外側の状態のままで、行の追加・削除・並べ替え・行フィールドの書き込みは双方向に届きます。
1200
+
1201
+ ```html
1202
+ <!-- ホスト側 -->
1203
+ <wcs-state json='{"rows":[{"name":"Alice"},{"name":"Bob"}]}'></wcs-state>
1204
+ <my-list data-wcs="state.items: rows"></my-list>
1205
+ ```
1206
+
1207
+ ```javascript
1208
+ // コンポーネント側(Shadow DOM)
1209
+ this.shadowRoot.innerHTML = `
1210
+ <wcs-state bind-component="state"></wcs-state>
1211
+ <ul>
1212
+ <template data-wcs="for: items">
1213
+ <li data-wcs="textContent: .name"></li>
1214
+ </template>
1215
+ </ul>
1216
+ `;
1217
+ ```
1218
+
1219
+ - ホストが `rows` を差し替えても、行フィールド(`rows.0.name`)だけを書いても、コンポーネント内の行に反映されます
1220
+ - コンポーネント側から `items.*.name` を書き戻すと、ホストの `rows` に届きます
1221
+ - **制限**: コンポーネント自身がホスト側の `for` の中にあり、さらにコンポーネント内でも `for` を回す
1222
+ 入れ子構成には対応していません(内外の行の対応付けが決まらないため)
1223
+
1159
1224
  ## Command Token(メソッドバインディング)
1160
1225
 
1161
1226
  プロパティバインディング(`state.message: user.name`)はコンポーネントへ流れ込むデータを扱いますが、**state からコンポーネントのメソッドを起動する**こと —— `<wcs-fetch>.fetch()`、`<wcs-dialog>.open()` など —— はカバーしません。**command token** は型付きの pub/sub チャネルでこの隙間を埋めます:
@@ -1455,6 +1520,8 @@ event token は command token と同じ `Token` pub/sub プリミティブを共
1455
1520
 
1456
1521
  command token / event token が運ぶのは離散的なやり取りです。**`$streams`** は残る形 —— 連続的なフローをカバーします。非同期 producer(async iterable / async generator / `ReadableStream`)を宣言すると、フレームワークがそれを **fold して単一の reactive プロパティに畳み込みます** —— 各チャンクは通常のパス代入を通るため、バインディング・パス getter・`$updatedCallback` は自分で値を代入した場合とまったく同じように反応します。`args` 関数が読んだ state パスが変化すると、実行中の producer は abort され、新しい引数で source が張り直されます(switchMap 型の依存駆動 restart)。stream は `$connectedCallback` 完了後に eager に起動し、要素の disconnect で abort されます。
1457
1522
 
1523
+ `$updatedCallback` は引き続き binding 駆動です。stream 宣言だけでは headless な購読にならず、その value/status/error の live DOM binding が実際に適用されたときだけ callback の path に現れます。現行 API に state-only な `$watch` / `$effects` 宣言はありません。観測契約は [stream リファレンス](docs/streams.md) を参照してください。
1524
+
1458
1525
  ```html
1459
1526
  <wcs-state>
1460
1527
  <script type="module">
@@ -1576,6 +1643,24 @@ chip.payload = null → element.data = null かつ removeAttribute("dat
1576
1643
  - ミラーはベストエフォート: `setAttribute` の失敗は握りつぶされ(`debug` 警告付き)、プロパティ書き込みをブロックしない
1577
1644
  - ネイティブ HTML 要素は `inputs` を完全に無視する —— ミラーは `static wcBindable` を公開するカスタム要素でのみ有効になる
1578
1645
 
1646
+ ## コンポーネント機構の選び方
1647
+
1648
+ カスタム要素に独自の状態を持たせる機構は 2 つあり、**排他**です。コンポーネントごとにどちらか一方を選びます。
1649
+
1650
+ | | [DCC](#宣言的カスタムコンポーネント-dcc) | [`bind-component`](#web-component-バインディング) |
1651
+ |---|---|---|
1652
+ | 要素の定義方法 | HTML だけ(`data-wc-definition` + Declarative Shadow DOM) | 自分で書く `class extends HTMLElement` |
1653
+ | 状態の在処 | テンプレート内のインライン `<script type="module">`(インスタンスごとにロード) | コンポーネントインスタンスのプロパティ(`this.state`) |
1654
+ | `static wcBindable` | `$bindables` / `$commands` から生成 | **無し** — wc-bindable の producer ではない |
1655
+ | 親から値をバインド | `count: parentCount`(双方向・変更イベントあり) | `state.msg: user.name`(パスマッピング) |
1656
+ | 親からメソッド起動 | `command.bumpBy: $command.bump` | 不可 — クラス側に公開して自分で呼ぶ |
1657
+ | spread(`...: obj`) | 使える | 使えない(`wcBindable` 宣言が必要) |
1658
+ | コンポーネント自身の読み書き | 要素の `this.count` | `this.state.msg` |
1659
+
1660
+ 判断の目安は「**JavaScript クラスが無いなら DCC、既にクラスを書いているなら `bind-component`**」です。併用はエラーになります。`data-wc-definition` ホストの中に `<wcs-state bind-component>` を置くのは設定ミスです — DCC の状態はテンプレートに属し、インスタンスごとにロードされるためです。
1661
+
1662
+ `bind-component` のコンポーネントが wc-bindable プロトコルの外に留まっているのは意図的です。宣言されたプロパティ面ではなく**パス**で配線するのがこの機構だからで、`wcBindable` 宣言を必要とする spread と command token が使えないのはその帰結です。
1663
+
1579
1664
  ## 宣言的カスタムコンポーネント (DCC)
1580
1665
 
1581
1666
  JavaScript のクラス定義なしで、**HTML だけ**でカスタム要素を定義できます。`data-wc-definition` と Declarative Shadow DOM (`<template shadowrootmode>`) を使い、リアクティブな状態を持つ再利用可能なコンポーネントをインラインで宣言します。
@@ -1620,23 +1705,55 @@ JavaScript のクラス定義なしで、**HTML だけ**でカスタム要素を
1620
1705
  [data-wc-definition] { display: none; }
1621
1706
  ```
1622
1707
 
1623
- ### `$bindables` と wc-bindable プロトコル
1708
+ ### `$bindables` / `$commands` と wc-bindable プロトコル
1624
1709
 
1625
- `$bindables` 配列は、変更イベント付きのコンポーネントプロパティとして公開する状態プロパティを宣言します。[wc-bindable プロトコル](https://github.com/nicenemo/nicenemo/blob/main/docs/wc-bindable-protocol.md)に準拠しています:
1710
+ `$bindables` は変更イベント付きのコンポーネント**プロパティ**として公開する状態プロパティを、`$commands` は起動可能な**コマンド**として公開する状態メソッドを宣言します。この 2 つで [wc-bindable プロトコル](https://github.com/nicenemo/nicenemo/blob/main/docs/wc-bindable-protocol.md)の宣言が組み立てられます:
1626
1711
 
1627
1712
  ```javascript
1628
1713
  export default {
1629
1714
  count: 0,
1630
- increment() { this.count++; },
1631
- $bindables: ["count"]
1715
+ bumpBy(step) { this.count += step; },
1716
+ $bindables: ["count"],
1717
+ $commands: ["bumpBy"]
1632
1718
  };
1633
1719
  ```
1634
1720
 
1635
1721
  これにより以下が生成されます:
1636
1722
 
1637
- - クラスの `static wcBindable` — フレームワークアダプタ用のプロトコルメタデータ。各 `$bindables` メンバは `properties` と `inputs` の両方に宣言され(双方向)、方向認識初期同期の下でも親 state → DCC の書き込みが機能します — [バインディング authority](#バインディング-authority-init--sync) 参照
1638
- - プロトタイプの getter/setter — リアクティブプロキシ経由で読み書き
1639
- - `CustomEvent` のディスパッチ — 値が変更されるたびに `my-counter:count-changed` が発火
1723
+ - クラスの `static wcBindable` — フレームワークアダプタ用のプロトコルメタデータ。各 `$bindables` メンバは `properties` と `inputs` の両方に宣言され(双方向)、方向認識初期同期の下でも親 state → DCC の書き込みが機能します — [バインディング authority](#バインディング-authority-init--sync) 参照。各 `$commands` メンバは `commands` エントリになります
1724
+ - `$bindables` はプロトタイプの getter/setter、`$commands` はメソッド いずれもリアクティブプロキシ経由
1725
+ - `CustomEvent` のディスパッチ — `$bindables` メンバが変更されるたびに `my-counter:count-changed` が発火
1726
+
1727
+ `commands` エントリは常に `async: true` です。DCC のメソッドは内側の `<wcs-state>` の初期化に chain するため、状態側のメソッドが `async` でなくても戻り値は Promise になります。
1728
+
1729
+ どちらの宣言もコンポーネント定義時に `$commandTokens` と同じ強度で検証されます。次はいずれもエラーになります。
1730
+
1731
+ - 配列でない
1732
+ - 非空文字列でないエントリ
1733
+ - `$` 始まりのエントリ(内部プロパティはコンポーネントの prototype に公開されません)
1734
+ - 重複したエントリ — これは従来サイレントに壊れていました。重複名があると `wcBindable` 宣言全体が読み取り不能になり、その要素が黙って双方向バインド不可になります
1735
+ - 状態に存在しないエントリ(自身とプロトタイプチェーンの両方を探索します。`$streams` の名前は値プロパティがインスタンスごとに実体化されるため「存在する」と見なされます)
1736
+ - `$bindables` にメソッドを書いた場合、または `$commands` に値プロパティを書いた場合
1737
+
1738
+ ### DCC のメソッドを起動する
1739
+
1740
+ `$commands` メンバは、I/O ノードと同じように親 state から [command token](#command-tokenメソッドバインディング) で起動できます:
1741
+
1742
+ ```html
1743
+ <wcs-state>
1744
+ <script type="module">
1745
+ export default {
1746
+ $commandTokens: ["bump"],
1747
+ fire() { this.$command.bump.emit(3); }
1748
+ };
1749
+ </script>
1750
+ </wcs-state>
1751
+
1752
+ <button data-wcs="onclick: fire">bump</button>
1753
+ <my-counter data-wcs="command.bumpBy: $command.bump"></my-counter>
1754
+ ```
1755
+
1756
+ 位置引数はそのまま素通しされるので、`emit(3)` はコンポーネント側の状態の `bumpBy(3)` を呼びます。
1640
1757
 
1641
1758
  ### DCC プロパティへのバインディング
1642
1759
 
@@ -1672,6 +1789,7 @@ export default {
1672
1789
  | プロパティ | 用途 |
1673
1790
  |----------|---------|
1674
1791
  | `$bindables` | 観測可能プロパティの宣言 |
1792
+ | `$commands` | 起動可能メソッドの宣言 |
1675
1793
  | `$connectedCallback` | ライフサイクルフック(各インスタンスで実行) |
1676
1794
  | `$disconnectedCallback` | クリーンアップフック |
1677
1795
  | `$updatedCallback` | 状態変更後に呼ばれる |
@@ -1719,14 +1837,14 @@ export default {
1719
1837
  |---|---|---|
1720
1838
  | `$connectedCallback` | 初回接続時は状態初期化後、再接続時は毎回呼び出し | 可(await される) |
1721
1839
  | `$disconnectedCallback` | 要素が DOM から削除された時 | 不可(同期のみ) |
1722
- | `$updatedCallback(paths, indexesListByPath)` | 状態変更が適用された後に呼び出し | 可(await されない) |
1840
+ | `$updatedCallback(paths, indexesListByPath)` | live binding に更新が適用された後に呼び出し | 可(await されない) |
1723
1841
 
1724
1842
  `$disconnectedCallback` を除くすべてのフックで `async` を使用できます。リアクティブ Proxy はすべてのプロパティへの代入を変更として検知します。そのため、標準の `async/await` による処理とプロパティへの直接代入だけで非同期ロジックが完結します。ローディングフラグの切り替え、取得したデータの格納、エラーメッセージの更新といった処理もすべて単なるプロパティ代入で行えるため、非同期状態を管理するための複雑な抽象化機能は必要ありません。
1725
1843
 
1726
1844
  - フック内の `this` は読み書き可能な状態プロキシです。
1727
1845
  - `$connectedCallback` は要素が接続される**たびに**呼ばれます(一度削除された後の再接続も含みます)。再確立が必要なセットアップ処理に適しています。
1728
1846
  - `$disconnectedCallback` は同期的に呼び出されます。タイマーのクリア、イベントリスナーの削除、リソースの解放といったクリーンアップ処理に使用してください。
1729
- - `$updatedCallback(paths, indexesListByPath)` は更新された状態パスの一覧を受け取ります。ワイルドカードをもつパスが更新された場合は、`indexesListByPath` から対象のインデックス情報も取得可能です。`async` を使用できますが、戻り値は await されません。
1847
+ - `$updatedCallback(paths, indexesListByPath)` は、その drain で live binding が適用された path の一覧を受け取ります。binding のない state 書き込みでは呼ばれず、`paths` にも現れません。ワイルドカードをもつパスが更新された場合は、`indexesListByPath` から対象のインデックス情報も取得可能です。`async` を使用できますが、戻り値は await されません。
1730
1848
  - Web Component を使用している場合は、コンポーネント側に `async $stateReadyCallback(stateProp)` を定義おくことで、`bind-component` でバインドした状態が利用可能になった瞬間にフックとして呼び出されます。
1731
1849
 
1732
1850
  ## 設定
package/README.md CHANGED
@@ -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`:
@@ -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
 
@@ -1157,6 +1194,34 @@ customElements.define("my-component", MyComponent);
1157
1194
  </template>
1158
1195
  ```
1159
1196
 
1197
+ ### Rendering a List Inside the Component
1198
+
1199
+ An array can be bound into a component and iterated with `for:` **inside** it. The outer state
1200
+ stays the source of truth; row additions, removals, reordering and row-field writes flow both ways.
1201
+
1202
+ ```html
1203
+ <!-- Host -->
1204
+ <wcs-state json='{"rows":[{"name":"Alice"},{"name":"Bob"}]}'></wcs-state>
1205
+ <my-list data-wcs="state.items: rows"></my-list>
1206
+ ```
1207
+
1208
+ ```javascript
1209
+ // Component (Shadow DOM)
1210
+ this.shadowRoot.innerHTML = `
1211
+ <wcs-state bind-component="state"></wcs-state>
1212
+ <ul>
1213
+ <template data-wcs="for: items">
1214
+ <li data-wcs="textContent: .name"></li>
1215
+ </template>
1216
+ </ul>
1217
+ `;
1218
+ ```
1219
+
1220
+ - Replacing `rows` or writing a single row field (`rows.0.name`) both reach the rows inside the component
1221
+ - Writing `items.*.name` from inside the component reaches the host's `rows`
1222
+ - **Limitation**: nesting is not supported — a component that sits inside a host `for:` *and* runs its own
1223
+ `for:` over a mapped array has no way to relate the outer row to the inner one
1224
+
1160
1225
  ## Command Token (Method Binding)
1161
1226
 
1162
1227
  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 +1521,8 @@ Event tokens share the same `Token` pub/sub primitive as command tokens — `nam
1456
1521
 
1457
1522
  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
1523
 
1524
+ `$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. There is currently no state-only `$watch` / `$effects` declaration; see the [stream reference](docs/streams.md) for the observation contract.
1525
+
1459
1526
  ```html
1460
1527
  <wcs-state>
1461
1528
  <script type="module">
@@ -1577,6 +1644,24 @@ Notes:
1577
1644
  - Mirror is best-effort: a `setAttribute` failure is swallowed (with a `debug` warning) and does not block the property write
1578
1645
  - Native HTML elements ignore `inputs` entirely — the mirror only activates for custom elements that expose `static wcBindable`
1579
1646
 
1647
+ ## Choosing a Component Mechanism
1648
+
1649
+ Two mechanisms give a custom element its own state, and they are **mutually exclusive** — pick one per component:
1650
+
1651
+ | | [DCC](#declarative-custom-components-dcc) | [`bind-component`](#web-component-binding) |
1652
+ |---|---|---|
1653
+ | How the element is defined | HTML only (`data-wc-definition` + Declarative Shadow DOM) | A JavaScript `class extends HTMLElement` you write |
1654
+ | Where the state lives | An inline `<script type="module">` in the template, loaded per instance | A property on the component instance (`this.state`) |
1655
+ | `static wcBindable` | Generated from `$bindables` / `$commands` | **None** — the element is not a wc-bindable producer |
1656
+ | Parent binds a value | `count: parentCount` (two-way, change events) | `state.msg: user.name` (path mapping) |
1657
+ | Parent invokes a method | `command.bumpBy: $command.bump` | Not available — expose it on the class and call it yourself |
1658
+ | Spread (`...: obj`) | Available | Not available (requires a `wcBindable` declaration) |
1659
+ | Component reads/writes its own state | `this.count` on the element | `this.state.msg` |
1660
+
1661
+ 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.
1662
+
1663
+ `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.
1664
+
1580
1665
  ## Declarative Custom Components (DCC)
1581
1666
 
1582
1667
  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 +1706,55 @@ The definition element is hidden; each instance clones the template into its own
1621
1706
  [data-wc-definition] { display: none; }
1622
1707
  ```
1623
1708
 
1624
- ### `$bindables` and wc-bindable Protocol
1709
+ ### `$bindables` / `$commands` and the wc-bindable Protocol
1625
1710
 
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):
1711
+ `$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/nicenemo/nicenemo/blob/main/docs/wc-bindable-protocol.md) declaration:
1627
1712
 
1628
1713
  ```javascript
1629
1714
  export default {
1630
1715
  count: 0,
1631
- increment() { this.count++; },
1632
- $bindables: ["count"]
1716
+ bumpBy(step) { this.count += step; },
1717
+ $bindables: ["count"],
1718
+ $commands: ["bumpBy"]
1633
1719
  };
1634
1720
  ```
1635
1721
 
1636
1722
  This generates:
1637
1723
 
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
1724
+ - `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
1725
+ - Getter/setter on the prototype for `$bindables`, a method for `$commands` both go through the reactive proxy
1726
+ - `CustomEvent` dispatch — `my-counter:count-changed` fires on every mutation of a `$bindables` member
1727
+
1728
+ `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`.
1729
+
1730
+ Both declarations are validated when the component is defined, with the same strictness as `$commandTokens`. Each of the following raises:
1731
+
1732
+ - not an array
1733
+ - an entry that is not a non-empty string
1734
+ - an entry starting with `$` (internal properties are never exposed on the component prototype)
1735
+ - 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
1736
+ - 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)
1737
+ - a method listed in `$bindables`, or a value property listed in `$commands`
1738
+
1739
+ ### Driving a DCC Method
1740
+
1741
+ A `$commands` member can be invoked from the parent state with a [command token](#command-token-method-binding), exactly like an I/O node:
1742
+
1743
+ ```html
1744
+ <wcs-state>
1745
+ <script type="module">
1746
+ export default {
1747
+ $commandTokens: ["bump"],
1748
+ fire() { this.$command.bump.emit(3); }
1749
+ };
1750
+ </script>
1751
+ </wcs-state>
1752
+
1753
+ <button data-wcs="onclick: fire">bump</button>
1754
+ <my-counter data-wcs="command.bumpBy: $command.bump"></my-counter>
1755
+ ```
1756
+
1757
+ Positional arguments pass through verbatim, so `emit(3)` calls `bumpBy(3)` on the component's state.
1641
1758
 
1642
1759
  ### Binding to DCC Properties
1643
1760
 
@@ -1673,6 +1790,7 @@ Properties prefixed with `$` are internal and not exposed on the component proto
1673
1790
  | Property | Purpose |
1674
1791
  |----------|---------|
1675
1792
  | `$bindables` | Declares observable properties |
1793
+ | `$commands` | Declares invocable methods |
1676
1794
  | `$connectedCallback` | Lifecycle hook (runs on each instance) |
1677
1795
  | `$disconnectedCallback` | Cleanup hook |
1678
1796
  | `$updatedCallback` | Called after state mutations |
@@ -1720,14 +1838,14 @@ State objects can define `$connectedCallback`, `$disconnectedCallback`, and `$up
1720
1838
  |---|---|---|
1721
1839
  | `$connectedCallback` | After state initialization on first connect; on every reconnect thereafter | Yes (awaited) |
1722
1840
  | `$disconnectedCallback` | When the element is removed from the DOM | No (sync only) |
1723
- | `$updatedCallback(paths, indexesListByPath)` | After state updates are applied | Yes (not awaited) |
1841
+ | `$updatedCallback(paths, indexesListByPath)` | After updates are applied to live bindings | Yes (not awaited) |
1724
1842
 
1725
1843
  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
1844
 
1727
1845
  - `this` inside hooks is the state proxy with full read/write access
1728
1846
  - `$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
1847
  - `$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
1848
+ - `$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
1849
  - In Web Components, define `async $stateReadyCallback(stateProp)` to receive a hook when the bound state becomes available via `bind-component`
1732
1850
 
1733
1851
  ## Configuration