@wcstack/state 2.1.1 → 2.2.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
@@ -117,7 +117,7 @@
117
117
  - **Mustache 構文** — テキストノードでの `{{ path|filter }}`
118
118
  - **複数の状態ソース** — JSON, JS モジュール, インラインスクリプト, API, 属性
119
119
  - **SVG サポート** — `<svg>` 要素内でのフルバインディング対応
120
- - **ライフサイクルフック** — `$connectedCallback` / `$disconnectedCallback` / `$updatedCallback`、Web Component 用 `$stateReadyCallback`
120
+ - **ライフサイクルフック** — `$connectedCallback` / `$disconnectedCallback` / `$updatedCallback` / `$errorCallback`、Web Component 用 `$stateReadyCallback`
121
121
  - **headless な watch** — `$watch` はパスが描画されていてもいなくても state の変化で発火する
122
122
  - **診断** — 解決しないパス・添字の本数・getter の循環を `@wcstack/lint`・VS Code 拡張と同じ診断 code で報告する
123
123
  - **TypeScript サポート** — `defineState()` によるドットパス自動補完付き型付き状態定義([詳細](docs/define-state.ja.md))。`@wcstack/typescript` は同じ型を HTML の検証器へ運び(`wcs-schema`)、インライン state スクリプトを型検査する(`wcs-tsc`)— [docs/typescript.ja.md](../../docs/typescript.ja.md)
@@ -247,7 +247,7 @@
247
247
  <div data-wcs="textContent: cart.total"></div>
248
248
  ```
249
249
 
250
- ボリュームは getter・`$watch`・`$listKeys`・`$updatedCallback`・`$connectedCallback`/`$disconnectedCallback` を宣言できます(すべてマウントパス相対)。読み込み順は自由です(ルートより先に接続されたボリュームは、ルートの登録時に接ぎ木されます)。マウントパスは静的パスのみです(`*`・`$`・`#`・`@` は不可)。初期化後に `mount` 属性を変更することはできません — 変更は console 警告付きで無視されます。要素を取り除き、望むパスで新しい要素を追加してください。
250
+ ボリュームは getter・`$watch`・`$listKeys`・`$updatedCallback`・`$connectedCallback`/`$disconnectedCallback` を宣言できます(すべてマウントパス相対)。`$errorCallback` はルート専用です(バインディングの失敗はツリーの所有者へ 1 回だけ報告されます)。読み込み順は自由です(ルートより先に接続されたボリュームは、ルートの登録時に接ぎ木されます)。マウントパスは静的パスのみです(`*`・`$`・`#`・`@` は不可)。初期化後に `mount` 属性を変更することはできません — 変更は console 警告付きで無視されます。要素を取り除き、望むパスで新しい要素を追加してください。
251
251
 
252
252
  > **v1 の名前付き状態からの移行:** `<wcs-state name="cart">` + `total@cart` は `<wcs-state mount="cart">` + `cart.total` になります。v2 では `name` 属性は fail-fast し、パス中の `@` は parse error です(どちらもこの誘導文付き)。移行の対応表: [docs/state-mount-design.md](../../docs/state-mount-design.md) §9。
253
253
 
@@ -362,6 +362,8 @@ property[#modifier]: path[|filter[|filter(args)...]]
362
362
 
363
363
  ### バインディング authority (`#init=` / `#sync=`)
364
364
 
365
+ **これが解決する問題。** バインディングが attach する時点で既に値を持っている要素 —— 永続値をロード済みの `<wcs-storage>`、時計、自分のスナップショットを復元するウィジェット —— は state の初期値に上書きされます。双方向バインディングの初期同期が state→element に書くからです。そのバインディングにだけ `#init=element` を付けると、初期同期は*要素*が勝ちます。以後の変更は通常どおり両方向に流れます。このケース(load-before-bind)は下で具体的に説明します。この節の残りは、それを一例として含む一般規則です。
366
+
365
367
  `static wcBindable` を宣言したカスタム要素への prop バインディングは、**authority**(バインディング attach 時の**初期同期**をどちら側が勝つか)を解決します。定常時の方向は authority とは別に、メンバの宣言形状で決まります: output-only メンバは state からの書き込みを恒久的に受け付けず(契約)、双方向メンバは初期同期の勝者と無関係に以後は両方向に流れます。既定 authority はメンバの宣言位置から導出されます(`enableDirectionalInitialSync` で既定 ON):
366
368
 
367
369
  | メンバの宣言位置 | 既定 authority | 効果 |
@@ -377,7 +379,7 @@ property[#modifier]: path[|filter[|filter(args)...]]
377
379
 
378
380
  要素が `properties[].event` を dispatch したとき、state に書かれる値は **`getter(event)`** です。`getter` を省略するとプロトコル既定の [`(e) => e.detail`](https://github.com/wc-bindable-protocol/wc-bindable-protocol/blob/main/SPEC.md#default-getter) が適用され、**`detail` 全体がそのまま**書かれます。このとき宣言したプロパティは要素から読まれ*ません*。イベントのペイロードが正です。`wcBindable` を持たない素の HTML 要素は逆で、`input`/`change` 時に `element[propName]` を読みます。
379
381
 
380
- したがって `getter` なしで `detail: { value: 7654321 }` を dispatch する要素は、数値ではなく**オブジェクト** `{ value: 7654321 }` を state に書きます。しかもこの失敗は無言です。書き戻し(`Number({ value: … })` → `NaN`)は警告を出さず、`@wcstack/lint` にも見えません(ペイロードの形は静的に分かりません)。次の 2 形のどちらかに揃えてください:
382
+ したがって `getter` なしで `detail: { value: 7654321 }` を dispatch する要素は、数値ではなく**オブジェクト** `{ value: 7654321 }` を state に書きます。しかもこの失敗はほぼ無言です。書き戻し(`Number({ value: … })` → `NaN`)は例外を投げず、`@wcstack/lint` にも見えません(ペイロードの形は静的に分かりません)。ランタイムは、イベント時点で見分けられる 2 形についてだけ、要素 × プロパティごとに 1 回警告します(`wcs/default-getter-mismatch`): 要素プロパティに値があるのに `detail` が `undefined`(素の `Event`、または `detail` の付け忘れ)、および要素プロパティがオブジェクトでないのに `detail` が `<propName>` キーを持つオブジェクト(上のラッパー)。それ以外の不整合は気づかれずに通り、どちらの場合も書き込み自体はそのまま行われます。次の 2 形のどちらかに揃えてください:
381
383
 
382
384
  ```javascript
383
385
  class YenInput extends HTMLElement {
@@ -944,6 +946,20 @@ export default {
944
946
 
945
947
  getter の例外は握り潰されません。評価された場所(バインディングの適用・`$watch` の評価・自分での読み取り)でそのまま表面化します。
946
948
 
949
+ #### 依存追跡の境界
950
+
951
+ 依存グラフに何が載るかは 3 つの規則で決まります。踏み越えるまで意識する必要はありませんが、踏み越えたときの症状は「値が更新されなくなる。エラーは出ない」なので、ここにまとめておきます:
952
+
953
+ | 規則 | 踏み越えたときの見え方 |
954
+ |---|---|
955
+ | **追跡されるのは `this` を通した *パス* の読み取りだけ。** `this.form` は `form` を、`this["form.name"]` は `form.name` を追跡する。`this.form.name` が追跡するのは **`form` だけ** —— `.name` は返ってきたオブジェクトへの素のプロパティアクセスでしかない。`Date.now()`・DOM・モジュール変数・クロージャで掴んだオブジェクトは何も登録しない | その入力に対して getter は二度と再評価されず、最初の値が残り続ける(上の例)。`this.form.name` を読む getter は、`<input data-wcs="value: form.name">` を編集しても再実行されない —— `this["form.name"]` で読む |
956
+ | **setter の中の読み取りは追跡しない。** setter は命令的な代入であって派生ではないので、その中で読んだものは何の依存にもならない | 何を書くかを `this.a` を読んで決める setter は、`a` が変わっても再実行されない。再実行されるのは getter だけ |
957
+ | **同値ガードはプリミティブにだけ効く。** 現在値と `Object.is` で等しいプリミティブの書き込みはキューに入る前に落とされる。オブジェクト・配列の書き込みは同じ参照でも必ず通る | 同じ文字列を再代入しても何も起きない。同じオブジェクトを再代入するとバインディングと `$watch` が再発火する(`config.sameValueGuard`。`semantics: "event"` のプロパティはどちらにせよ対象外) |
958
+
959
+ 規則 1 は静的解析で捕まえられる唯一の規則です。getter が `this.form.name` を読んでいて、ドキュメントのどこかで `form.name` を書いている(`value:` バインド・spread・`this["form.name"] = …`)と、`wcs-validate` と VS Code 拡張が `wcs/getter-untracked-read` を報告します。ルートを丸ごと置換するだけの設計(router の params・`$streams` の fold)には出ません。
960
+
961
+ `$untrackDependency(fn)` は setter の規則を getter に意図的に適用するもので、`fn` の中の読み取りは追跡されません。`$trackDependency(path)` は最初の規則に対する逃げ道です。
962
+
947
963
  ### ループインデックス変数(`$1`, `$2`, ...)
948
964
 
949
965
  getter やイベントハンドラ内で、`this.$1`、`this.$2` などで現在のループイテレーションのインデックスを取得できます(0始まりの値、1始まりの命名):
@@ -1334,6 +1350,23 @@ customElements.define("user-card", UserCard);
1334
1350
  > `$postUpdate` はコンポーネント自身の語彙で書けます — パスはマウント先へ翻訳され、ホスト行の
1335
1351
  > 添字は自動で前置されます。
1336
1352
 
1353
+ #### 公開 getter(コンポーネントの getter を外から読む)
1354
+
1355
+ マウントされたコンポーネントの getter はマウント先に**公開**されます: **ツリーに無いキーの読みは、その位置にマウントされたコンポーネントの getter で答える。ツリーにあるキーはツリーが勝つ。私有キーとメソッドは見せない。** 上の `user-card` なら、ホスト側のマークアップからコンポーネントの派生値をそのまま読めます:
1356
+
1357
+ ```html
1358
+ <user-card data-wcs="state: user"></user-card>
1359
+ <span data-wcs="textContent: user.display"></span> <!-- "Alice <alice@example.com>" — コンポーネントの getter -->
1360
+ ```
1361
+
1362
+ - 行マウントは行ごとに公開されます: `$getAll("users.*.display")` や同じ `for` 内の `text: .display` は各行のコンポーネントの getter を読みます。依存も流れます — `user.name` が変われば `user.display` を読んだものはすべて再描画されます
1363
+ - `get "children.*.label"()` のようにコンポーネント内のパスにワイルドカードを含む accessor は、内部では使えますが**公開されません**。各子行にマウントしたコンポーネントの `get label()` として定義してください。公開対象は、公開パスのワイルドカード数がマウント先のワイルドカード数と一致する accessor だけです
1364
+ - 親は子コンポーネントの登録より先に評価されるので、初回の読みは `undefined` になりえます。コンポーネントがマウントされ次第、値は収束します。派生式は `(x ?? 0)` のように防御的に書いてください
1365
+ - 未存在パスの警告は `getBindingsReady` とは独立に 1 マクロタスク(`setTimeout(0)`)だけ遅延します。autoloader や遅延したカスタム要素定義を使う場合、最終的にバインドが解決しても、登録前の初回警告が出ることがあります
1366
+ - ツリーに同名のキーがある場合(継承したプロパティも含む)はツリーが勝ち、ランタイムが 1 回だけ warn します(`wcs/mount-export-shadowed`)。同一インスタンスに同名キーを公開するコンポーネントが 2 つある形は設定ミスで、候補走査時に検出して throw します(`wcs/mount-export-ambiguous`)。検証付きキャッシュに命中すると他の候補は再走査しないため、初回解決後に追加した競合コンポーネントは検出されない場合があります
1367
+ - 公開キーへの外からの書き込みは accessor の setter を実行し、getter しか無ければ throw します(getter を隠すキーをツリーに作ることはありません)。`in` は公開キーを見ません
1368
+ - **自己再帰コンポーネント**(深さが有界でない木)が書けるようになります: 自分自身を `<template data-wcs="for: children"><tree-node data-wcs="state: ."></tree-node></template>` で入れ子にするコンポーネントは、`get total() { return this.value + this.$getAll("children.*.total").reduce((a, b) => a + (b ?? 0), 0); }` と各段 1 段の式で書け、再帰は台帳が解きます。パス自体は再帰を表現できない(ワイルドカードの個数が固定)ので、再帰は DOM に置き、パスはその展開形になります。設計: [docs/state-overlay-export-design.md](../../docs/state-overlay-export-design.md)
1369
+
1337
1370
  ### 独立した Web Component への状態注入(`__e2e__/single-component`)
1338
1371
 
1339
1372
  ホストの外部状態に依存しないコンポーネントでも、`bind-component` で `state` を注入してリアクティブにできます。
@@ -2120,7 +2153,7 @@ export default {
2120
2153
 
2121
2154
  ## ライフサイクルフック
2122
2155
 
2123
- 状態オブジェクトに `$connectedCallback` / `$disconnectedCallback` / `$updatedCallback` を定義すると、初期化・クリーンアップ・更新時のフックとして利用できます。
2156
+ 状態オブジェクトに `$connectedCallback` / `$disconnectedCallback` / `$updatedCallback` / `$errorCallback` を定義すると、初期化・クリーンアップ・更新時・バインディング失敗時のフックとして利用できます。
2124
2157
 
2125
2158
  ```html
2126
2159
  <wcs-state>
@@ -2150,6 +2183,7 @@ export default {
2150
2183
  | `$connectedCallback` | 初回接続時は状態初期化後、再接続時は毎回呼び出し | 可(await される) |
2151
2184
  | `$disconnectedCallback` | 要素が DOM から削除された時 | 不可(同期のみ) |
2152
2185
  | `$updatedCallback(paths, indexesListByPath)` | live binding に更新が適用された後に呼び出し | 可(await されない) |
2186
+ | `$errorCallback(error, info)` | バインディングの適用に失敗した drain の後 — 失敗した本数ぶん、`$updatedCallback` の後に呼び出し | 可(await されない) |
2153
2187
 
2154
2188
  `$disconnectedCallback` を除くすべてのフックで `async` を使用できます。リアクティブ Proxy はすべてのプロパティへの代入を変更として検知します。そのため、標準の `async/await` による処理とプロパティへの直接代入だけで非同期ロジックが完結します。ローディングフラグの切り替え、取得したデータの格納、エラーメッセージの更新といった処理もすべて単なるプロパティ代入で行えるため、非同期状態を管理するための複雑な抽象化機能は必要ありません。
2155
2189
 
@@ -2157,6 +2191,19 @@ export default {
2157
2191
  - `$connectedCallback` は要素が接続される**たびに**呼ばれます(一度削除された後の再接続も含みます)。再確立が必要なセットアップ処理に適しています。
2158
2192
  - `$disconnectedCallback` は同期的に呼び出されます。タイマーのクリア、イベントリスナーの削除、リソースの解放といったクリーンアップ処理に使用してください。
2159
2193
  - `$updatedCallback(paths, indexesListByPath)` は、その drain で live binding が適用された path の一覧を受け取ります。binding のない state 書き込みでは呼ばれず、`paths` にも現れません。ワイルドカードをもつパスが更新された場合は、`indexesListByPath` から対象のインデックス情報も取得可能です。マウントされたコンポーネントのマーカーパス(`#m…`)は `paths` に現れません — コンポーネントの私有キーは私有のままです(DevTools の overlays 表示で見えます)。`async` を使用できますが、戻り値は await されません。
2194
+ - `$errorCallback(error, info)` はバインディングの**ページ内エラー境界**です。バインディングの適用が throw したとき(パス getter やフィルタが throw した、構造ディレクティブが失敗した)、その失敗は隔離され(同じバッチの残りは適用され、値も DOM も巻き戻されません)、このフックが無ければ `console.error` で報告されます。フックを宣言すると報告はそこへ届きます: `error` は throw された値、`info` はバインディングを識別する `{ path, bindingType, node }`(`path` は `data-wcs` に書いた形のまま。ワイルドカードもそのまま)。`this` は書き込み可能な state proxy なので、メッセージを state に書いて普通に描画するのが基本形です:
2195
+
2196
+ ```js
2197
+ export default {
2198
+ user: null, loadError: "",
2199
+ get title() { return this.user.profile.name; }, // user が null の間は throw する
2200
+ $errorCallback(error, { path }) {
2201
+ this.loadError = `${path}: ${error.message}`; // <p data-wcs="textContent: loadError">
2202
+ },
2203
+ };
2204
+ ```
2205
+
2206
+ フックはバッチの後(`$updatedCallback` の後)に走り、await されず、フック内で throw しても console に報告されるだけで drain は壊れません。DevTools にはフックの有無に関わらず全失敗が `state:binding-apply-error` として届きます。ルート専用で、ボリューム(`<wcs-state mount>`)に宣言しても無視されます。`$watch` ハンドラの失敗(別途隔離・報告)や、`$connectedCallback` / `$updatedCallback` が投げた例外(loud に失敗する)は対象外です。
2160
2207
  - Web Component を使用している場合は、コンポーネント側に `async $stateReadyCallback(stateProp)` を定義おくことで、`bind-component` でバインドした状態が利用可能になった瞬間にフックとして呼び出されます。
2161
2208
 
2162
2209
  ## 遷移アニメーション
@@ -2312,6 +2359,21 @@ bootstrapState({
2312
2359
  [docs/i18n-design.md](../../docs/i18n-design.md) を参照。短く言えば、翻訳はフィルタでは
2313
2360
  なくパスに置く。
2314
2361
 
2362
+ **i18n の位置づけと、決めたこと。** i18n パッケージもライブな言語切替も、意図して
2363
+ 持たない。辞書はロケールごとに選ばれる ES モジュールで、ボリューム
2364
+ (`<wcs-state mount="i18n" src="/i18n/state.js">`)としてマウントし、普通のパス
2365
+ (`i18n.checkout.title`)で読む。
2366
+ ロケールはページが描画される**前**に決める — フィルタは `<html lang>` から、router は
2367
+ URL から。router 側のロケールはルートパラメータではなく **basename**(`/ja/…`)に置く。
2368
+ したがって言語切替は state への書き込みではなく、別の basename への実ナビゲーションで
2369
+ ある: router は自分の basename 配下のリンクだけを intercept するので、`/:lang` パラメータ
2370
+ にすると言語が変わらないまま静かに壊れ、ライブ切替はロケール依存の全モジュールの
2371
+ 再評価を要求する。basename を運ぶ `<base href>` には実コストがある(ページ内アンカー・
2372
+ SVG フラグメント参照・CSP 下の相対 `src` が全てそこを基準に解決される)。代替 2 案 —
2373
+ router 自身が `<html lang>` を読む案と、リンク単位の intercept オプトアウト — も検討し、
2374
+ 記録として残した。別の形を選ぶ前に [docs/i18n-design.md](../../docs/i18n-design.md)
2375
+ §9-1 を読むこと。`examples/router-i18n` が参照レイアウト。
2376
+
2315
2377
  > この 3 つは **architecture-hardening** 機能で、規範は `docs/architecture-hardening/` に
2316
2378
  > あります。`enablePropagationContext` は**既定 on** — write-path コストは一方向バインドで
2317
2379
  > ほぼゼロ(echo しうる双方向 wire のみ因果 bookkeeping を行う)で、フラグは恒久的な
package/README.md CHANGED
@@ -117,7 +117,7 @@ That's it. No build, no bootstrap code, no framework.
117
117
  - **Mustache syntax** — `{{ path|filter }}` in text nodes
118
118
  - **Multiple state sources** — JSON, JS module, inline script, API, attribute
119
119
  - **SVG support** — full binding support inside `<svg>` elements
120
- - **Lifecycle hooks** — `$connectedCallback` / `$disconnectedCallback` / `$updatedCallback`, plus `$stateReadyCallback` for Web Components
120
+ - **Lifecycle hooks** — `$connectedCallback` / `$disconnectedCallback` / `$updatedCallback` / `$errorCallback`, plus `$stateReadyCallback` for Web Components
121
121
  - **Headless watch** — `$watch` fires on state changes whether or not the path is rendered
122
122
  - **Diagnostics** — unresolved paths, index arity and getter cycles are reported with the same codes as `@wcstack/lint` and the VS Code extension
123
123
  - **TypeScript support** — `defineState()` for typed state definitions with dot-path autocompletion ([details](docs/define-state.md)); `@wcstack/typescript` carries the same types into the HTML validator (`wcs-schema`) and type-checks inline state scripts (`wcs-tsc`) — see [docs/typescript.md](../../docs/typescript.md)
@@ -247,7 +247,7 @@ There is **one state tree per root**. To split state across modules, mount a vol
247
247
  <div data-wcs="textContent: cart.total"></div>
248
248
  ```
249
249
 
250
- A volume may declare getters, `$watch`, `$listKeys`, `$updatedCallback`, and `$connectedCallback`/`$disconnectedCallback` — all relative to its mount path. Load order does not matter (a volume connected before the root is grafted when the root registers). Mount paths must be static (`*`, `$`, `#`, `@` are rejected). Changing `mount` after the element has initialized is not supported: the change is ignored with a console warning — remove the element and add a new one with the desired path.
250
+ A volume may declare getters, `$watch`, `$listKeys`, `$updatedCallback`, and `$connectedCallback`/`$disconnectedCallback` — all relative to its mount path. `$errorCallback` is root-only (a binding failure is reported once, to the tree's owner). Load order does not matter (a volume connected before the root is grafted when the root registers). Mount paths must be static (`*`, `$`, `#`, `@` are rejected). Changing `mount` after the element has initialized is not supported: the change is ignored with a console warning — remove the element and add a new one with the desired path.
251
251
 
252
252
  > **Migrating from v1's named states:** `<wcs-state name="cart">` + `total@cart` becomes `<wcs-state mount="cart">` + `cart.total`. In v2 the `name` attribute fails fast and `@` in a path is a parse error, each with this exact guidance. Migration table: [docs/state-mount-design.md](../../docs/state-mount-design.md) §9.
253
253
 
@@ -362,6 +362,8 @@ Automatically enabled for:
362
362
 
363
363
  ### Binding Authority (`#init=` / `#sync=`)
364
364
 
365
+ **The problem this solves.** An element that already holds a value when its binding attaches — `<wcs-storage>` after loading a persisted value, a clock, a widget restoring its own snapshot — is overwritten by the state seed, because the initial sync of a two-way binding writes state→element. Adding `#init=element` to that one binding makes the *element* win the initial sync instead; later changes flow both ways as usual. That case (load-before-bind) is spelled out below; the rest of this section is the general rule it is an instance of.
366
+
365
367
  For custom elements that declare `static wcBindable`, every prop binding resolves an **authority** — which side wins the **initial sync** when the binding attaches. The steady-state direction is decided separately, by the member's declared shape: an output-only member never accepts state writes (a permanent contract), while a two-way member flows both ways after the initial sync regardless of which side won it. The default authority is derived from where the member is declared (on by default via `enableDirectionalInitialSync`):
366
368
 
367
369
  | Member declared in | Default authority | Effect |
@@ -377,7 +379,7 @@ For custom elements that declare `static wcBindable`, every prop binding resolve
377
379
 
378
380
  When the element dispatches `properties[].event`, the value written to state is **`getter(event)`**. With no `getter`, the protocol default applies — [`(e) => e.detail`](https://github.com/wc-bindable-protocol/wc-bindable-protocol/blob/main/SPEC.md#default-getter): the **whole `detail`, as-is**. The declared property is *not* read off the element at that point; the event payload is authoritative. A plain HTML element (no `wcBindable`) is the other way round: `element[propName]` is read on `input`/`change`.
379
381
 
380
- So an element that dispatches `detail: { value: 7654321 }` without a `getter` writes the **object** `{ value: 7654321 }` to state, not the number — and the failure is silent: the write-back (`Number({ value: … })` → `NaN`) produces no warning, and `@wcstack/lint` cannot see it (the payload shape is not static). Use one of the two conforming shapes:
382
+ So an element that dispatches `detail: { value: 7654321 }` without a `getter` writes the **object** `{ value: 7654321 }` to state, not the number — and the failure is mostly silent: the write-back (`Number({ value: … })` → `NaN`) throws nothing, and `@wcstack/lint` cannot see it (the payload shape is not static). The runtime warns once per element and property (`wcs/default-getter-mismatch`) for the two shapes it can tell apart at the event: a `detail` that is `undefined` while the element property has a value (a plain `Event`, or a forgotten `detail`), and a `detail` object carrying a `<propName>` key while the property is not an object (the wrapper above). Any other mismatch goes through unnoticed, and the write is applied as-is either way. Use one of the two conforming shapes:
381
383
 
382
384
  ```javascript
383
385
  class YenInput extends HTMLElement {
@@ -944,6 +946,20 @@ export default {
944
946
 
945
947
  Getters that throw are not swallowed: the exception surfaces where the getter was evaluated (a binding apply, a `$watch` evaluation, or your own read).
946
948
 
949
+ #### Dependency tracking boundaries
950
+
951
+ Three rules decide what the dependency graph sees. None of them matters until you cross one, and when you do the symptom is a value that stops updating with no error — so they are collected here:
952
+
953
+ | Rule | What it looks like when crossed |
954
+ |---|---|
955
+ | **Only path reads through `this` are tracked.** `this.form` tracks `form`; `this["form.name"]` tracks `form.name`; `this.form.name` tracks **`form` only** — the `.name` is a plain property access on the object that came back. `Date.now()`, the DOM, a module variable, a closed-over object register nothing | The getter is never re-evaluated for that input; the first value sticks (the examples above). A getter that reads `this.form.name` does not re-run when a bound `<input data-wcs="value: form.name">` changes — read `this["form.name"]` |
956
+ | **Reads inside a setter are not tracked.** A setter is an imperative assignment, not a derivation, so nothing it reads becomes a dependency of anything | A setter that reads `this.a` to decide what to write does not run again when `a` changes — only a getter re-runs |
957
+ | **The same-value guard applies to primitives only.** A primitive write `Object.is`-equal to the current value is dropped before anything is enqueued; an object or array write always passes, even the same reference | Assigning the same string again fires nothing; assigning the same object again re-fires its bindings and `$watch` (`config.sameValueGuard`; a `semantics: "event"` property is exempt either way) |
958
+
959
+ The first rule is the one static analysis can catch: `wcs-validate` and the VS Code extension report `wcs/getter-untracked-read` when a getter reads `this.form.name` and the document writes `form.name` somewhere (a `value:` binding, a spread, `this["form.name"] = …`). A root that is only ever replaced wholesale — router params, a `$streams` fold — is left alone.
960
+
961
+ `$untrackDependency(fn)` applies the setter rule to a getter on purpose: reads inside `fn` are not tracked. `$trackDependency(path)` is the escape hatch for the first rule.
962
+
947
963
  ### Loop Index Variables (`$1`, `$2`, ...)
948
964
 
949
965
  Inside getters and event handlers, `this.$1`, `this.$2`, etc. provide the current loop iteration index (0-based value, 1-based naming):
@@ -1334,6 +1350,23 @@ customElements.define("user-card", UserCard);
1334
1350
  > (and on `this` inside getters/methods) speak the component's own vocabulary — paths are
1335
1351
  > translated onto the mount and the host row's indexes are prepended automatically.
1336
1352
 
1353
+ #### Exported getters (reading a component's getter from outside)
1354
+
1355
+ A mounted component's getters are **exported** at the mount point: **a read of a key the tree does not have is answered by the getter of the component mounted there. A key the tree does have wins. Private keys and methods are never visible.** With the `user-card` above, the host can bind `session`-level markup to the component's derived value:
1356
+
1357
+ ```html
1358
+ <user-card data-wcs="state: user"></user-card>
1359
+ <span data-wcs="textContent: user.display"></span> <!-- "Alice <alice@example.com>" — the component's getter -->
1360
+ ```
1361
+
1362
+ - Row mounts export per row: `$getAll("users.*.display")` and `text: .display` inside the same `for` read each row component's getter. Dependencies flow through: when `user.name` changes, everything that read `user.display` re-renders.
1363
+ - Accessors whose component-local path contains a wildcard, such as `get "children.*.label"()`, work inside the component but are **not exported**. Define `get label()` on a component mounted on each child row instead. Only accessors whose exported path has the mount point's wildcard count are exported.
1364
+ - The parent evaluates before the child component registers, so the first read may see `undefined`; the value converges as soon as the component mounts. Write derived expressions defensively (`(x ?? 0)`).
1365
+ - Missing-path warnings are deferred by one macrotask (`setTimeout(0)`), independently of `getBindingsReady`. With an autoloader or delayed custom-element definition, an initial warning may appear before the component registers, even when the binding eventually resolves.
1366
+ - If the tree already has the key (including an inherited property), the tree wins and the runtime warns once (`wcs/mount-export-shadowed`). Two components exporting the same key on the same instance is a configuration error detected during candidate scans (`wcs/mount-export-ambiguous`). A validated cache hit does not rescan other candidates, so adding a conflicting component after the first resolution may escape detection.
1367
+ - Writing to an exported key from outside runs the accessor's setter, or throws if it only has a getter (the tree never grows a key that would hide the getter). `in` does not see exported keys.
1368
+ - **Self-recursive components** (trees of unbounded depth) become expressible: a component that renders `<template data-wcs="for: children"><tree-node data-wcs="state: ."></tree-node></template>` inside itself can define `get total() { return this.value + this.$getAll("children.*.total").reduce((a, b) => a + (b ?? 0), 0); }` — each level's formula closes over one level, and the ledger resolves the recursion. Paths cannot express recursion themselves (their wildcard count is fixed), so the recursion lives in the DOM and the paths are its unrolled form. Design: [docs/state-overlay-export-design.md](../../docs/state-overlay-export-design.md).
1369
+
1337
1370
  ### Standalone Web Component Injection (`__e2e__/single-component`)
1338
1371
 
1339
1372
  Even when a component is independent from outer host state, you can inject reactive state with `bind-component`.
@@ -2124,7 +2157,7 @@ All bindings work inside `<svg>` elements. Use `attr.*` for SVG attributes:
2124
2157
 
2125
2158
  ## Lifecycle Hooks
2126
2159
 
2127
- State objects can define `$connectedCallback`, `$disconnectedCallback`, and `$updatedCallback` for initialization, cleanup, and update lifecycle handling.
2160
+ State objects can define `$connectedCallback`, `$disconnectedCallback`, `$updatedCallback`, and `$errorCallback` for initialization, cleanup, update, and binding-failure handling.
2128
2161
 
2129
2162
  ```html
2130
2163
  <wcs-state>
@@ -2154,6 +2187,7 @@ State objects can define `$connectedCallback`, `$disconnectedCallback`, and `$up
2154
2187
  | `$connectedCallback` | After state initialization on first connect; on every reconnect thereafter | Yes (awaited) |
2155
2188
  | `$disconnectedCallback` | When the element is removed from the DOM | No (sync only) |
2156
2189
  | `$updatedCallback(paths, indexesListByPath)` | After updates are applied to live bindings | Yes (not awaited) |
2190
+ | `$errorCallback(error, info)` | After a drain in which a binding failed to apply — once per failed binding, after `$updatedCallback` | Yes (not awaited) |
2157
2191
 
2158
2192
  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.
2159
2193
 
@@ -2161,6 +2195,19 @@ All hooks except `$disconnectedCallback` support `async` — you can use `async/
2161
2195
  - `$connectedCallback` is called **every time** the element is connected (including re-insertion after removal), making it suitable for setup that should be re-established
2162
2196
  - `$disconnectedCallback` is called synchronously — use it for cleanup such as clearing timers, removing event listeners, or releasing resources
2163
2197
  - `$updatedCallback(paths, indexesListByPath)` receives the paths whose live bindings were applied in that drain. Unbound state writes do not invoke it or appear in `paths`. For wildcard updates, `indexesListByPath` contains the updated index sets. Marker paths of mounted components (`#m…`) never appear in `paths` — a component's private keys stay private (DevTools shows them in its overlays view). Can be `async`, but the return value is not awaited
2198
+ - `$errorCallback(error, info)` is the in-page **error boundary** for bindings. When applying a binding throws — a path getter or filter threw, a structural directive failed — the failure is isolated (the rest of the batch still applies, and neither the value nor the DOM is rolled back) and, without this hook, reported with `console.error`. Declare the hook and the report comes to you instead: `error` is what was thrown, `info` is `{ path, bindingType, node }` identifying the binding (`path` as written in `data-wcs`, wildcards intact). `this` is the writable state proxy, so the usual shape is to write the message into state and render it like anything else:
2199
+
2200
+ ```js
2201
+ export default {
2202
+ user: null, loadError: "",
2203
+ get title() { return this.user.profile.name; }, // throws while user is null
2204
+ $errorCallback(error, { path }) {
2205
+ this.loadError = `${path}: ${error.message}`; // <p data-wcs="textContent: loadError">
2206
+ },
2207
+ };
2208
+ ```
2209
+
2210
+ The hook runs after the batch (after `$updatedCallback`), is not awaited, and an exception thrown inside it is reported to the console without breaking the drain. DevTools still receives every failure as `state:binding-apply-error` whether or not the hook exists. Root-only: a volume (`<wcs-state mount>`) declaring it is ignored. It does not cover `$watch` handlers (isolated and reported separately) or errors thrown by `$connectedCallback` / `$updatedCallback` (those fail loudly).
2164
2211
  - In Web Components, define `async $stateReadyCallback(stateProp)` to receive a hook when the bound state becomes available via `bind-component`
2165
2212
 
2166
2213
  ## Transition animations
@@ -2318,6 +2365,22 @@ of the binding expression: `price|locale(fr-FR)`. For a page that switches
2318
2365
  language without reloading, see [docs/i18n-design.md](../../docs/i18n-design.md) —
2319
2366
  the short answer is that translations belong on a path, not in a filter.
2320
2367
 
2368
+ **Where i18n sits, and what was decided.** There is no i18n package and no live
2369
+ language switch, on purpose. A dictionary is an ES module chosen per locale and mounted as a
2370
+ volume (`<wcs-state mount="i18n" src="/i18n/state.js">`), then read as ordinary
2371
+ paths (`i18n.checkout.title`); the locale is decided **before** the page renders — from
2372
+ `<html lang>` for the filters, and from the URL for the router, where the locale
2373
+ lives in the `basename` (`/ja/…`) rather than in a route parameter. Switching
2374
+ language is therefore a real navigation to another basename, not a state write:
2375
+ the router intercepts links under its own basename only, so a `/:lang` parameter
2376
+ would silently keep the old language, and a live switch would need every locale-
2377
+ dependent module to re-evaluate. The `<base href>` that carries the basename has a
2378
+ real cost (page-fragment anchors, SVG fragment references, relative `src` under
2379
+ CSP all resolve against it), and two alternatives were weighed — the router reading
2380
+ `<html lang>` itself, and a per-link opt-out of interception — and left recorded.
2381
+ Read [docs/i18n-design.md](../../docs/i18n-design.md) §9-1 before choosing a
2382
+ different shape; `examples/router-i18n` is the reference layout.
2383
+
2321
2384
  > These three are **architecture-hardening** features; their normative reference is
2322
2385
  > `docs/architecture-hardening/`. `enablePropagationContext` defaults **on** — its
2323
2386
  > write-path cost is near-zero for one-way bindings (only echo-capable two-way