@wcstack/state 3.3.0 → 3.4.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 +37 -10
- package/README.md +37 -10
- package/dist/auto.min.js +1 -1
- package/dist/auto.min.js.map +1 -1
- package/dist/index.d.ts +29 -0
- package/dist/index.esm.js +1 -1
- package/dist/index.esm.js.map +1 -1
- package/dist/split/chunks/binder.js +1 -1
- package/dist/split/chunks/binder.js.map +1 -1
- package/dist/split/chunks/checkDependency.js +1 -1
- package/dist/split/chunks/checkDependency.js.map +1 -1
- package/dist/split/chunks/diagnosticsHooks.js.map +1 -1
- package/dist/split/chunks/featureBridge.js +1 -1
- package/dist/split/chunks/filterRegistry.js +1 -1
- package/dist/split/chunks/filterRegistry.js.map +1 -1
- package/dist/split/chunks/getAllPropertyDescriptors.js +1 -1
- package/dist/split/chunks/lifecycleHooks.js +1 -1
- package/dist/split/chunks/pathDiagnostics.js +1 -1
- package/dist/split/chunks/pathDiagnostics.js.map +1 -1
- package/dist/split/chunks/registerComponents.js +1 -1
- package/dist/split/chunks/registerComponents.js.map +1 -1
- package/dist/split/chunks/setAll.js +1 -1
- package/dist/split/chunks/setAll.js.map +1 -1
- package/dist/split/chunks/version.js +1 -1
- package/dist/split/core.d.ts +54 -0
- package/dist/split/core.js +1 -1
- package/dist/split/features/devtools.js +1 -1
- package/dist/split/features/devtools.js.map +1 -1
- package/dist/split/features/diagnostics.js +1 -1
- package/dist/split/features/diagnostics.js.map +1 -1
- package/dist/split/features/formats.js +1 -1
- package/dist/split/features/recursion.js +1 -1
- package/dist/split/features/recursion.js.map +1 -1
- package/dist/split/features/scopes.js +1 -1
- package/dist/split/features/scopes.js.map +1 -1
- package/dist/split/features/ssr.js +1 -1
- package/dist/split/features/ssr.js.map +1 -1
- package/dist/split/features/temporal.js +1 -1
- package/dist/split/features/temporal.js.map +1 -1
- package/package.json +1 -1
package/README.ja.md
CHANGED
|
@@ -186,9 +186,9 @@
|
|
|
186
186
|
<script type="importmap">
|
|
187
187
|
{
|
|
188
188
|
"imports": {
|
|
189
|
-
"@wcstack/state/core": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.
|
|
190
|
-
"@wcstack/state/features/temporal": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.
|
|
191
|
-
"@wcstack/state/features/scopes": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.
|
|
189
|
+
"@wcstack/state/core": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.4.0/dist/split/core.js",
|
|
190
|
+
"@wcstack/state/features/temporal": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.4.0/dist/split/features/temporal.js",
|
|
191
|
+
"@wcstack/state/features/scopes": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.4.0/dist/split/features/scopes.js"
|
|
192
192
|
}
|
|
193
193
|
}
|
|
194
194
|
</script>
|
|
@@ -669,7 +669,7 @@ export default {
|
|
|
669
669
|
- spread 右辺へのフィルタ(`...: target|filter`)はエラー
|
|
670
670
|
- 右辺パスの途中に `*` を含めても OK(例:`...: stores.*.fetch`)
|
|
671
671
|
- 右辺は素のツリーパス(`...: fetchX`、途中の `*` も可)
|
|
672
|
-
- カスタム要素クラスが未登録の場合、`customElements.whenDefined(tag)` 解決時に遅延展開される(autoloader
|
|
672
|
+
- カスタム要素クラスが未登録の場合、`customElements.whenDefined(tag)` 解決時に遅延展開される(autoloader による遅延ロードに対応)。`for:` / `if:` のテンプレートの中でも同じ — 行や枝はすぐに描かれ、クラスが定義されたらそれぞれの行の要素をその行の値で展開する。定義前に消えた行・閉じた枝は展開しない。待つ間に文書から外した要素(タブやパネルの DOM 退避)は、外れている間はレジストリに何も残さず、戻ったら戻った先のツリーのレジストリで待ち直し、クラスが定義されたら(定義済みならすぐに)展開する(行の要素は、その行がまだ一覧に居る間だけ)
|
|
673
673
|
- `wcBindable` 宣言**のない**要素はエラー(明示配線で書いてください)。spread は何を展開すべきかを契約から読み取るため
|
|
674
674
|
|
|
675
675
|
**Composite shell**(wc-bindable Composition Profile)はそのままサポートされます:composite shell は標準の `target.constructor.wcBindable` を通じて synthesized declaration を露出するため、`"s3.progress"` のような composed name はフラットな要素メンバーキーとして扱われます。state を composed 構造に合わせて (`{ s3: { progress: 0 } }`) 持てば、`...: pipeline` が自動的に nested state path へ展開されます。
|
|
@@ -1052,7 +1052,7 @@ export default {
|
|
|
1052
1052
|
|
|
1053
1053
|
3. **キャッシュ** — getter の結果は具体的なアドレス(パス + ループインデックス)ごとにキャッシュされます。`users.*.fullName` のインデックス 0 とインデックス 1 は別々のキャッシュエントリを持ちます。依存先が変更された場合のみキャッシュが無効化されます。
|
|
1054
1054
|
|
|
1055
|
-
4. **直接インデックスアクセス** — 数値インデックスで特定の要素にアクセスすることもできます:`this["users.0.name"]` はループコンテキストなしで `users[0].name`
|
|
1055
|
+
4. **直接インデックスアクセス** — 数値インデックスで特定の要素にアクセスすることもできます:`this["users.0.name"]` はループコンテキストなしで `users[0].name` に解決されます。要素のパスへ別のオブジェクトを代入する(`this["users.0"] = { ...this["users.0"], name: "z" }`)と、`for:` でリストを描いているかどうかに関わらず、その位置は新しい行になります。その下のパス(`users.0.name`・`$getAll("users.*.name")`・行 getter)は、差し替える前の行のキャッシュではなく新しいオブジェクトから読みます。その場で書き換えた同じオブジェクトを書き戻す(`const u = this["users.0"]; u.name = "z"; this["users.0"] = u;`)か、`$postUpdate("users.0")` / `$postUpdate("users")` で知らせると、行は据え置いたまま、その下のパスとそれを読む getter を読み直します — 行の中でそれらを描く `for:` が無くても同じです。この修正の前は、`for:` が描いていない限りその下のパスはキャッシュの値のままで、同じリストを `$getAll` で集める getter が先に評価されていると、`users.0.name` を読む getter が要素の差し替えを取りこぼすことがありました。数値の添字が 1 つのパスなら、バインディングにも同じ綴りを書けて、意味も同じ「*いま 0 番目にある行*」です:`textContent: users.0.name`、`{{ users.1.name }}`、行 getter なら `{{ users.0.fullName }}`。添字のパスへの書き込み(`this["users.0.name"] = …`)、その行を描く `for:` の行やその行に束縛した入力欄からの書き込み、要素の差し替え(`this["users.0"] = {…}`)、並べ替え・行の削除・リストの丸ごと置換に追従し、行 getter はその入力の変化に追従します — リストを `for:` で描いていてもいなくても同じです。このバインディングは state オブジェクトに足す列挙されないアクセサを通して getter と同じように読まれ、getter と同じく、リストのどの行への書き込みでも評価し直されます。そのため `$watch` のキー(`"users.0.name"` など)は値が変わらないまま発火することがあります。その位置に行が無い(空のリスト)ときは空で、配列でないオブジェクトの下のキー(`sales.2024.total`)は素のキーとして読みます。3.3.0 までは、この書き方のバインディングは最初の値のまま止まってリストの丸ごと置換にしか追従せず、行 getter をこの綴りで書くと空のまま `wcs/binding-path-missing` の警告が出ていました。数値の添字が 2 つ以上のパス(`groups.0.items.1.v`)と、拡張できない state オブジェクト(`Object.freeze`・`Object.seal`・`Object.preventExtensions`)の上の数値添字のバインディングは、いまもそのままです。
|
|
1056
1056
|
|
|
1057
1057
|
### getter は state に対して純粋であること
|
|
1058
1058
|
|
|
@@ -1129,6 +1129,7 @@ export default {
|
|
|
1129
1129
|
- **`path` への書き込み** — オブジェクトを含むどんな値でも(`$eq("selected", this["items.*"])` に対する `this.selected = row`): 選ばれていた行と、新たに選ばれる行。
|
|
1130
1130
|
- **`path` より上のオブジェクトへの書き込み** — `$eq("sel.id", …)` に対する `this.sel = { id: 2 }`: 同じく 2 行。旧いオブジェクトの下と新しいオブジェクトの下で `path` が持つ値を鍵にします。
|
|
1131
1131
|
- **`path` が getter か getter の配下**(`get current()` に対する `$eq("current.id", …)`): 値が書き込みなしに変わるので、依存を張る普通の読み取りに戻ります。選択は正しく保たれますが、変化のたびに全行が再評価されます(鍵付きの形を使わないのと同じ)。2 行で済ませるには、`path` を書き込まれる state(`selectedId`)に向けてください。`@wcstack/devtools` の State ペインは、path ごとの購読の数を **Keyed selection** に出し、この形に落ちた path には `tracked` バッジを付けます(3.0)。
|
|
1132
|
+
- **数値の添字を含む `path`**(`$eq("items.0.v", …)`): 書き込みは添字をワイルドカードにした綴り(`items.*.v`・`items.*`)で知らせるので、綴りのままの購読には届きません。この形も依存を張る普通の読み取りに戻り、その行の `v` への書き込み・要素の差し替え・リストの差し替えに追従します(getter と同じく、別の行の `v` への書き込みでも再評価されます)。この修正の前は、同じ数値のパスのバインディングが無い限り最初の値のまま止まっていました。
|
|
1132
1133
|
- **型変換はしません:** `"2"` は id `2` に一致しません。`<input>` や `<select>` は文字列を書くので、入口で変換する(`value|number: selectedId`)か、id を文字列で持ってください。
|
|
1133
1134
|
- **スコープの中では `path` は相対**です — パスを取る他の `$` API と同じです。マウントされたコンポーネント(`bind-component`)とボリューム(`<wcs-state mount="cart">`)では、`$eq("selectedId", …)`・`$eqPath` の 2 つのパス・`$eqIndex`・`$dependOn` はどれもそのスコープ(`cart.selectedId`)に解決され、ルートには向きません。`$untracked` はコールバックを取るので翻訳の対象外です。
|
|
1134
1135
|
|
|
@@ -1277,6 +1278,12 @@ export default {
|
|
|
1277
1278
|
|
|
1278
1279
|
この書き方で行を入れ替えると、入れ替えが揃った時点で、描画済みの行が値と一緒に並べ替わります(その場で中身を書き換えるのではありません)。行の `$1` と、バインドの外にある行の状態(バインドしていない input に入力中のテキストなど)も値に付いてきます。リストに無かった値を書き込むと、その行はその場で置き換わります。ブロックは動かず、バインドが新しい値を映すので、行にバインドした input は入力中もフォーカスを保ちます。プリミティブのリストでは等しい値を区別できないため、書き込みの結果が同じ値の並べ替えになれば入れ替えとして扱います。
|
|
1279
1280
|
|
|
1281
|
+
入れ替えが揃うのは、動かした値がどれも元の位置を離れたときです。それまで(2 回の書き込みの間、動かした値が両方の位置にある間)は、行はブロックを保ったまま位置どおりの値を映し、添字のパス(`this["items.0"]`)はその位置を読み書きします。2 回の書き込みは別々の更新(2 回のクリックなど)に分かれていても構いません。入れ替えを揃えた更新が `items.*` の `$watch` に知らせるのは、その更新で書いた位置だけです(`prev` もその位置の値です)。揃う前に配列を代入すると、前にそのパスが持っていた配列でも、代入した配列の値が描かれます。別々の行に同じ値を持つリストもほかと同じく入れ替わりますが、別の行が持ち続ける値を写す書き込みは、入れ替えの片側と区別できません。そのため、コードが同じ値を持ち続ける行を作ると、配列を置き換えるまで、その配列への添字の書き込みは位置どおりに描かれます。値と `$1` は正しく、ブロックが値に付いて動かなくなるだけです。
|
|
1282
|
+
|
|
1283
|
+
行の中の要素が自分の行へ書く値には、ここまでの話は当てはまりません(`value: .` でバインドした input、`status: .` でバインドした wc-bindable の出力)。その書き込みは行の値の更新で、入れ替えの一部には数えません。行はブロックと `$1` を保ち、いくつもの行が同じ値を返しても、ある行の要素が別の行の持つ値を書いても、その要素の以後の書き込みは自分の行に届きます。
|
|
1284
|
+
|
|
1285
|
+
**同じオブジェクトが 2 つの位置にあるとき。** リストの 2 つの位置に同じオブジェクトがあると(`items: [o, o, …]`)、片方の行の下のキーへの書き込み(`this["items.0.name"] = "z"`)でオブジェクトは変わりますが、もう片方の行は古い値を表示し、読みも古い値を返します。行の下の値は行ごとにキャッシュされ、書き込みはそれを通した行にしか届かないためです。こうした書き込みの後に `$postUpdate("items")` を呼ぶと、`for:` の行・`$getAll("items.*.name")`・`items.1.name` のような添字のパスのすべてがオブジェクトを読み直します。
|
|
1286
|
+
|
|
1280
1287
|
## 再帰パス(`$recursion`)
|
|
1281
1288
|
|
|
1282
1289
|
パスは深さを文字列に焼き付けます。`nodes.*.children.*.total` はワイルドカードちょうど 2 段のパスであり、木が 1 段深くなっても 3 段には伸びません。しかし木の深さはコードではなく**データの性質**です。`$recursion` はこの隔たりを埋めます。形が繰り返す場所を宣言し、あとは「いま何段目であれ」を `**` と書きます。
|
|
@@ -1426,7 +1433,9 @@ this.$setAll("nodes.**.selected", [], false); // 全深さの全ノード
|
|
|
1426
1433
|
|
|
1427
1434
|
走査は深さ方向に降りながら、必要な形をその場で検査します。**同じ配列インスタンス**に 2 度到達したら拒否します。その配列が現在のノードの祖先のものなら循環(`wcs/recursion-cycle`)、そうでなければ 2 つのノードが 1 本の子リストを共有しています(`wcs/recursion-shared-list`)。各ノードに自分の `children` 配列を持たせてください —— **空**配列の使い回しは行を持たず別名化のしようがないので、追跡もせず正当です。
|
|
1428
1435
|
|
|
1429
|
-
行オブジェクトを作り直して `children` 配列を引き継ぐ置換 —— `this.nodes = this.nodes.map(n => ({ ...n }))` —— はふつうの更新で、集計もそのまま追従します。子リストの行オブジェクトはそのまま生き残り(行の identity で持っているもの —— `bind-component` の子スコープが描画した行や、そこにバインドしていない状態 —— は保たれます)、行がぶら下がっていた**退役した親**だけが生きている行に差し替わるので、次の葉の更新は画面に出ている行を dirty にします([#256](https://github.com/wcstack/wcstack/issues/256))。2 つの行が 1 本の `children` 配列を**共有**する形はこれとは別です。2 行とも配列に居る間は従来どおりで、1 本の配列には 1 組の行しかないので 2 行は必ず同じ値で一致し、親を読む行 getter(`this["nodes.*.value"]
|
|
1436
|
+
行オブジェクトを作り直して `children` 配列を引き継ぐ置換 —— `this.nodes = this.nodes.map(n => ({ ...n }))` —— はふつうの更新で、集計もそのまま追従します。子リストの行オブジェクトはそのまま生き残り(行の identity で持っているもの —— `bind-component` の子スコープが描画した行や、そこにバインドしていない状態 —— は保たれます)、行がぶら下がっていた**退役した親**だけが生きている行に差し替わるので、次の葉の更新は画面に出ている行を dirty にします([#256](https://github.com/wcstack/wcstack/issues/256))。2 つの行が 1 本の `children` 配列を**共有**する形はこれとは別です。2 行とも配列に居る間は従来どおりで、1 本の配列には 1 組の行しかないので 2 行は必ず同じ値で一致し、親を読む行 getter(`this["nodes.*.value"]`)はその行集合の**持ち主**(最初にその配列を展開した行)の文脈で評価されます。変わったのは**持ち主をリストから外したとき**で、行集合はリストに残っている行のどれか 1 行へ移ります(`if:` で隠れている行のこともあります) —— その行の集計が、外した行の数字で凍る代わりに共有データを追従します(行集合は 1 組しかないので、3 行で共有していれば残った 1 行だけが追従し、他は凍ったままです)。3.4.0 からは、持ち主がリストに残ったまま配列を手放したとき(`this["nodes.0.children"] = [ … ]`)も同じで、行集合は同じキーでその配列を持つ別の行へ移り、その行の集計が共有データを追従します(3.3.0 では凍ったままでした)。外した行**そのもののオブジェクト**が戻ってくれば持ち主も戻ります —— 同じ配列インスタンスでも、同じ行を並べた新しい配列でも、違う位置に戻しても返ります。一方、**全ての行を作り直す**綴り(`this.nodes = this.nodes.map(n => ({ ...n }))` —— この項の冒頭の更新)ではどの行も一致しないので、行集合は**持ち主が居た位置を占める行**に付きます。2.3.0 でこの通りに戻るのは同じ配列インスタンスの綴りだけで、新しい配列で戻すと 2.3.0 では両方の行が凍ります。子の getter が上を読むなら、ノードごとに自分の配列を持たせてください —— 持ち主が配列を手放した後、そうした getter は、読む値が変わるまで前の持ち主の文脈で求めた値を返し続けることがあります。
|
|
1437
|
+
|
|
1438
|
+
共有の形のうち次の 3 つは既知の制約です。別の外側の行が同じ配列を**別のキー**に持つと(ある行は `items` に、別の行は `alt` に)、片方のキーを通した書き込みが別の要素に着地することがあります([#396](https://github.com/wcstack/wcstack/issues/396))。同じ配列を 2 つの `<wcs-state>`(ルートとそのボリュームを含む)で共有しないでください —— 片方で写しや作り直しをした後、もう片方を通した書き込みが別の配列に着地したり、投げて失われたりします([#397](https://github.com/wcstack/wcstack/issues/397))。また、共有した配列の写し・移動・作り直しを 1 回の更新に重ねると、state は正しいのに一部の `for:` の描画が古いまま残ることがあります([#398](https://github.com/wcstack/wcstack/issues/398))。
|
|
1430
1439
|
|
|
1431
1440
|
上限は展開後のパスの**ワイルドカード 128 段**です。上の集計 getter は評価中のノードより 1 段下を読むので、127 段の鎖までは畳めて、128 段で `wcs/recursion-depth-exceeded` になります(アンカー・到達した深さ・組み立てようとしたパス・上限を名指しします)。この検査は getter 評価スタック自身の 128 段の上限(`wcs/getter-depth-exceeded`)より先に効くので、深い木は「深い」と報告され、循環の疑いを掛けられることはありません。途中で打ち切ることもしません —— 部分的な集計は、誤った値を正しい値として返すことだからです。
|
|
1432
1441
|
|
|
@@ -1733,6 +1742,7 @@ customElements.define("user-card", UserCard);
|
|
|
1733
1742
|
- 部分マウントを併用できます: `state: user; state.theme: theme` は `theme` を 2 つ目の入口としてマウントします(最長接頭辞が勝つので、中の `theme.mode` はツリーの `theme.mode` を読みます)
|
|
1734
1743
|
- ループでは**行そのもの**をマウントします: `<template data-wcs="for: users"><user-row data-wcs="state: ."></user-row></template>`。行コンポーネントの中の `name` は `users.*.name`、中の `for: tags` は `users.*.tags.*` を回します
|
|
1735
1744
|
- **自前のキーは私有**です([docs/state-mount-design.md](../../docs/state-mount-design.md) §4-3 の R1): コンポーネントが自分で宣言したデータキー(`state = { mode: "view" }`)はその要素のもので、ツリーには書かれません。マウント先に同名のキーがあってそれを隠す形(`user.name` の上に `state = { name: "" }`)では、ランタイムが 1 回だけ warn します(`wcs/mount-own-key-shadow`)— ツリーを読みたければ既定値を消し、私有のままにしたければ名前を変えてください
|
|
1745
|
+
- `element.state` から取り出したメソッド(`card.state.toggle()`)は、イベントのバインディング(`onclick: toggle`)から呼んだときと同じに動きます。自前のキーへの書き込みもツリーのキーへの書き込みも描き直され、行マウントではその行に着地し、同期メソッドは値を、async メソッドは Promise を返します
|
|
1736
1746
|
- 配列そのものをルートにマウントする形(`state: rows` + 中で `for`)は非対応です。行をマウントする(`state: .`)か、配列を持つオブジェクトをマウントして中で `for` を回してください(`state: group` + `for: children`)。どちらも契約テストで固定されており、マウントがツリー拡張の唯一の手段です
|
|
1737
1747
|
|
|
1738
1748
|
> プロパティ単位の形(`state.message: user.name`)はそのまま動きます — 同じ機構の上の部分マウントです。
|
|
@@ -1826,6 +1836,8 @@ customElements.define("my-component", MyComponent);
|
|
|
1826
1836
|
</template>
|
|
1827
1837
|
```
|
|
1828
1838
|
|
|
1839
|
+
行の中のコンポーネントは自分の行を読み書きします。`$connectedCallback` / `$disconnectedCallback` の `this`(と、そこからタイマーや `await` の先へ取っておいた `this`)は要素の行に付いていきます — `if:` が要素を隠している間もその行に書き、要素の書き込み(`users[1] = …`)で行がその場で差し替わったら新しい行に書きます。要素がいったん外されて別の行に使い回された後は、取っておいた `this` はどちらの行にも書かずに `The host row of <user-row> was removed.` で投げます。行そのものが消えたときの `$disconnectedCallback` は、コンポーネント自身のキーを読み書きでき(`this.tid` に取っておいたタイマーの id を解除する)、行のキー(`this.name`)を読むと同じエラーで投げます。部分マウント(`state.name: .name`)の自身のキーは行ごとではなく要素ごとに 1 組なので、`$disconnectedCallback` がそこへ書いた値は、要素が別の行に使い回された後も残ります。
|
|
1840
|
+
|
|
1829
1841
|
### コンポーネント側でリストを描画する
|
|
1830
1842
|
|
|
1831
1843
|
配列をコンポーネントに束ね、`for:` をコンポーネントの内側で回すこともできます。
|
|
@@ -2365,9 +2377,9 @@ $renderedCallback(paths) {
|
|
|
2365
2377
|
- **中間値は観測できません** —— 1 バッチ内の `a → b → c` は `cur = c` / `prev = a` で 1 回だけ発火します(binding 更新と同じ契約)。
|
|
2366
2378
|
- **行は drain の時点のリストに従います** —— 同じ job で行を書いてから取り除いた・置き換えた・リストを短くした行は発火せず、位置だけが移った行も発火しません。1 つの位置が発火するのは多くても 1 回です。入れ子のリストを置き換えると、新しい配列の行がすべて発火します。
|
|
2367
2379
|
- **行単位の差分を見たいなら `$listKeys`** —— 未宣言のまま配列全体を代入すると、行 watch は**全行**について `prev === undefined` で発火します(どの行もパス書き込みを通っていないため)。`$listKeys` を宣言すればキー突合が per-field 書き込みに分解するので、変化した行だけが発火し `prev` もスカラで取れます。
|
|
2368
|
-
- **headless な行 watch には `$listKeys` が必要** —— `$watch` が単独では headless にならない唯一の箇所です。`items` から `items.*.price` への展開はリストの `for` バインディングが駆動しており、watch を宣言してもそのパスをリストとしては登録しません(意図的)。したがって `for` バインドも `$listKeys` も無い状態で配列を代入すると、行 watch は**一度も**発火しません。`$listKeys` を宣言する(キー突合がフィールドごとにパス書き込みするので展開を経由しない)か、リストを描画してください。スカラーパスは `user.name` のようなネストしたものも含め、この条件なしに headless
|
|
2380
|
+
- **headless な行 watch には `$listKeys` が必要** —— `$watch` が単独では headless にならない唯一の箇所です。`items` から `items.*.price` への展開はリストの `for` バインディングが駆動しており、watch を宣言してもそのパスをリストとしては登録しません(意図的)。したがって `for` バインドも `$listKeys` も無い状態で配列を代入すると、行 watch は**一度も**発火しません。`$listKeys` を宣言する(キー突合がフィールドごとにパス書き込みするので展開を経由しない)か、リストを描画してください。スカラーパスは `user.name` のようなネストしたものも含め、この条件なしに headless で発火します。`for` が無ければ、同じ配列やその写しの代入と `$postUpdate("items")` でも発火しません。行の値を読み直させるだけなので、それを読む getter(`$getAll("items.*.price", [])`)はその場で変えた値を読みます。
|
|
2369
2381
|
- **ハンドラの例外は隔離されます** —— throw はコンソールに報告され、残りの watch(と stream の restart)は続行します。loud fail する `$connectedCallback` / `$renderedCallback` とは異なる扱いです。
|
|
2370
|
-
- **書き込みの連鎖には上限があります** —— ハンドラの書き込みは新しいバッチを作るため、相互に書き合う watch は無限ループになり得ます。32 段で打ち切り、コンソールに報告します(値と DOM
|
|
2382
|
+
- **書き込みの連鎖には上限があります** —— ハンドラの書き込みは新しいバッチを作るため、相互に書き合う watch は無限ループになり得ます。32 段で打ち切り、コンソールに報告します(値と DOM は巻き戻しません)。段は書き込みごとに数えます。ハンドラ(と `$scan` の畳み)は自分を起こした書き込みの深さから始まるので、連鎖を伸ばすのは、ハンドラの書き込みが別のハンドラを起こしたときだけです。長い有限の描画の連鎖(`$renderedCallback` が描画ごとに見出しを 1 段ずつ詰める)を見るハンドラは、自分の書き込みが同じバッチに載っても打ち切られません。`$stream` の再開もハンドラと同じく数えます。再開の書き込み(`initial` への戻し・status)は再開を起こした書き込みの連鎖の続きで、上限を越えた書き込みが起こす再開は行いません。そのため、stream の `args` へ書き、再開でまた起こされる `$watch` も、`args` で互いの値を読む stream どうしも打ち切られます —— 上限を越えたバッチは、`$watch` を宣言していなくても報告されます。
|
|
2371
2383
|
- **マウントされた `bind-component` スコープでは実行されません** —— マウントされたコンポーネントは宣言面を実行せず、`$watch` の宣言があると 1 回だけ console.warn でルート state(またはボリューム —— `<wcs-state mount>` は `$watch` / `$listKeys` / `$renderedCallback` を持てます)へ誘導します(`$stream` も同様)。plain な(配線なし Shadow の)子は独立ツリーを持つので宣言できます。
|
|
2372
2384
|
- **SSR では実行されません** —— ハンドラの副作用がサーバーとクライアントで二重に走るためです。
|
|
2373
2385
|
|
|
@@ -2806,6 +2818,20 @@ this.$getAll("matrix.*.*", [row]);
|
|
|
2806
2818
|
|
|
2807
2819
|
隔離しない場合、1 本の throw が「値は新しいのに DOM は途中まで」という半端な状態を作り、しかも `$watch` と stream の restart が丸ごと消えていました(README のこの下にある発火順の契約が黙って破れる)。
|
|
2808
2820
|
|
|
2821
|
+
### 描画中の書き込みは無限ループになりません
|
|
2822
|
+
|
|
2823
|
+
バインディングの適用の最中に起きる書き込みがあります。`for:` の新しい行の中の wc-bindable 要素が初期値を state へ渡す(出力専用メンバー・`#init=element`)ときや、`$renderedCallback` が書くときです。こうした書き込みはそれぞれ新しいバッチを作り、そのバッチがまた描画されます。描画が毎回新しい値を書く形 —— たとえば行の要素の出力を、その一覧の getter が読むキーそのものへ束ね、要素がインスタンスごとに違う値を返す —— では、microtask が回り続けてページが固まります。この連鎖は 100 段で打ち切ります。101 段目になるバッチは描画せず(値は state に残ります)、そのバッチに載っていたパスを 1 回だけ報告します:
|
|
2824
|
+
|
|
2825
|
+
```
|
|
2826
|
+
[@wcstack/state] render chain depth limit exceeded; bindings for this batch were not applied. { maxDepth: 100, paths: ["mode", "view", "view.*", "view.*.m"] }
|
|
2827
|
+
```
|
|
2828
|
+
|
|
2829
|
+
上限がほかの 2 つ(32)より大きいのは、ここの 1 段が 1 回の描画だからです。描いて測って直す連鎖は、32 段を超えても正当に収まります —— 見出しの文字サイズを 64px から 18px まで 1px ずつ詰めると描画は 47 回、400 行を 10 行ずつ描くと 40 段です。
|
|
2830
|
+
|
|
2831
|
+
連鎖を伸ばすのは、バインディングの適用中に**同期で**起きた書き込みだけです。バインディングが要素のプロパティを設定したとき、要素がその場で同期に出したイベントもこれに含まれます。drain の外から届く書き込み —— 利用者の操作・非同期に届く I/O ノードのイベント・`$stream` の値・`await` の続き —— は 0 から数え直させます。`$scan`・`$watch`・`$stream` の restart の書き込みは、伸ばしも数え直させもせず、連鎖をそのまま次のバッチへ引き継ぎます。そのため `$watch` や `$scan` を挟む循環(要素が `x` へ書き、`$watch: { x(v) { this.mode = v } }`、一覧が `mode` を読む)も、描画 1 回で 1 段ずつ伸びて打ち切られます。これは `$watch` の上限には掛かりません —— ハンドラの書き込みは、直接別のハンドラを起こすのではなく、描画を挟んで戻ってくるからです。普通の一覧の行の初期同期のように収まる書き戻しは 1 段で止まります。連鎖を引き継ぐので、`$watch` や `$scan` でつないだ有限の連鎖は 100 段を分け合います。47 段で収まる見出しの詰めを、終わったら `$watch` が次の詰めを始める形で 2 本つなぐと収まります(描画 94 回)が、同じように 3 本つなぐと 3 本目の途中で打ち切られます。報告は連鎖ごとに 1 回です。描画しなかったバッチが `$watch` や `$scan` を通して連鎖をさらに引き継いでも、続くバッチは報告せずに描画しません。打ち切った後も、次の外からの書き込みは普通に描画されます。DevTools には `state:render-chain-limit` が届きます。
|
|
2832
|
+
|
|
2833
|
+
循環を閉じる書き込みが microtask から届く形は、この上限の対象外です(上限が無かったときと同じです):値を microtask で出す要素(Lit の `updated()` など)と、`await` の後で書く async の `$renderedCallback` です。こうした書き込みは、有限の非同期のループと見分けられません —— 解決済みの Promise を `await` しながら 1 万回書く `async` 関数も、マクロタスクを 1 回も挟まずに 1 万回描画します。続いた描画の数に上限を設けると、正当なコードまで止めてしまいます。
|
|
2834
|
+
|
|
2809
2835
|
### 値と DOM は巻き戻しません
|
|
2810
2836
|
|
|
2811
2837
|
異常系はすべて「報告して続行」で、適用済みの値を戻すことはありません。これは以下で共通の姿勢です:
|
|
@@ -2813,7 +2839,8 @@ this.$getAll("matrix.*.*", [row]);
|
|
|
2813
2839
|
| 機構 | 上限 | 超過時 |
|
|
2814
2840
|
|---|---|---|
|
|
2815
2841
|
| 因果伝播の hop | 32 | その transaction の未処理レコードのみ quarantine |
|
|
2816
|
-
| `$watch` の書き込み連鎖 | 32 | そのバッチの watch
|
|
2842
|
+
| `$watch` の書き込み連鎖 | 32 | そのバッチの `$scan` / `$watch` の発火と、上限を越えた書き込みが起こす `$stream` の再開をスキップ |
|
|
2843
|
+
| 描画中の書き込みの連鎖 | 100 | そのバッチのバインディング適用をスキップ |
|
|
2817
2844
|
| バインディングの適用失敗 | — | その 1 本のみスキップ |
|
|
2818
2845
|
|
|
2819
2846
|
## 設定
|
|
@@ -3222,7 +3249,7 @@ const html = await renderToString(template, {
|
|
|
3222
3249
|
|---------|------|
|
|
3223
3250
|
| **サーバー** | `renderToString()` が happy-dom でテンプレートを実行、`$connectedCallback`(`fetch()` 含む)を実行し、全バインディングを適用、ハイドレーションデータを含む `<wcs-ssr>` 要素付きのレンダリング済み HTML を出力 |
|
|
3224
3251
|
| **クライアント** | `<wcs-state enable-ssr>` が `<wcs-ssr>` の JSON から状態をロード、`$connectedCallback` をスキップ、`hydrateBindings()` が既存の DOM にリアクティビティを接続 |
|
|
3225
|
-
| **フォールバック** |
|
|
3252
|
+
| **フォールバック** | サーバー/クライアントのバージョン不一致時、またはサーバーが描いた `for:` の行が別の `for:` / `if:` / `elseif:` / `else:` ブロックの中にあるとき(そのハイドレーションは既知の制限)、SSR DOM をクリーンアップして `buildBindings()` でフルクライアントサイドレンダリングを実行。どちらも理由を `console.warn` で 1 回知らせる |
|
|
3226
3253
|
|
|
3227
3254
|
### `enable-ssr` の動作
|
|
3228
3255
|
|
package/README.md
CHANGED
|
@@ -187,9 +187,9 @@ leaves features out, it can compose them instead:
|
|
|
187
187
|
<script type="importmap">
|
|
188
188
|
{
|
|
189
189
|
"imports": {
|
|
190
|
-
"@wcstack/state/core": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.
|
|
191
|
-
"@wcstack/state/features/temporal": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.
|
|
192
|
-
"@wcstack/state/features/scopes": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.
|
|
190
|
+
"@wcstack/state/core": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.4.0/dist/split/core.js",
|
|
191
|
+
"@wcstack/state/features/temporal": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.4.0/dist/split/features/temporal.js",
|
|
192
|
+
"@wcstack/state/features/scopes": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.4.0/dist/split/features/scopes.js"
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
</script>
|
|
@@ -671,7 +671,7 @@ Runtime reads `customClass.wcBindable.properties + inputs` and expands each name
|
|
|
671
671
|
- Filters on the spread target (`...: target|filter`) are rejected.
|
|
672
672
|
- The right-hand path may contain `*` anywhere (e.g. `...: stores.*.fetch`).
|
|
673
673
|
- The right-hand side is a plain tree path (`...: fetchX` or `...: stores.*.fetch`).
|
|
674
|
-
- If the custom element class is not yet registered, expansion is deferred until `customElements.whenDefined(tag)` resolves — autoloader-style late registration is supported.
|
|
674
|
+
- If the custom element class is not yet registered, expansion is deferred until `customElements.whenDefined(tag)` resolves — autoloader-style late registration is supported. This holds inside `for:` / `if:` templates too: the rows and the branch render right away, and each row's element is expanded with that row's values once the class is defined. A row removed, or a branch closed, before the definition is not expanded. An element taken out of the document while it waits (a tab or panel parked off-DOM) holds nothing in the registry while it is out; once it is back, it waits again on the registry of the tree it is back in, and is expanded when the class is defined (at once if it already is) — for an element of a row, only while that row is still in the list.
|
|
675
675
|
- Elements **without** a `wcBindable` declaration are rejected (write bindings explicitly). Spread requires the contract to know what to expand.
|
|
676
676
|
|
|
677
677
|
**Composite shells** (wc-bindable Composition Profile) are supported transparently: a composite shell exposes its synthesized declaration through the standard `target.constructor.wcBindable` surface, and composed names like `"s3.progress"` are kept as flat element member keys. Mirror the composed structure in state (`{ s3: { progress: 0 } }`) and `...: pipeline` expands into nested state paths automatically.
|
|
@@ -1054,7 +1054,7 @@ Two-way binding works with path setters — editing the input calls the setter,
|
|
|
1054
1054
|
|
|
1055
1055
|
3. **Caching** — Getter results are cached per concrete address (path + loop index). `users.*.fullName` at index 0 has a separate cache entry from index 1. The cache is invalidated only when dependencies change.
|
|
1056
1056
|
|
|
1057
|
-
4. **Direct index access** — You can also access specific elements by numeric index: `this["users.0.name"]` resolves as `users[0].name` without needing loop context.
|
|
1057
|
+
4. **Direct index access** — You can also access specific elements by numeric index: `this["users.0.name"]` resolves as `users[0].name` without needing loop context. Assigning a different object to an element path (`this["users.0"] = { ...this["users.0"], name: "z" }`) makes that position a new row, whether or not a `for:` renders the list, so the paths below it (`users.0.name`, `$getAll("users.*.name")`, row getters) read the new object instead of the cached values of the row it replaced. Assigning the same object back after changing it in place (`const u = this["users.0"]; u.name = "z"; this["users.0"] = u;`), or announcing the change with `$postUpdate("users.0")` / `$postUpdate("users")`, keeps the row and re-reads the paths below it and the getters that read them, also with no `for:` rendering them inside the row — before this fix those paths kept their cached values unless a `for:` rendered them, and a getter reading `users.0.name` could miss a replaced element when a `$getAll` over the same list was evaluated first. A binding path with one numeric index can use the same spelling, with the same meaning — *whatever row is at index 0 now*: `textContent: users.0.name`, `{{ users.1.name }}`, or a row getter such as `{{ users.0.fullName }}`. It follows a write through an index path (`this["users.0.name"] = …`), a `for:` row or an input bound to that row, replacing the element (`this["users.0"] = {…}`), reordering, removing rows and replacing the list, and a row getter follows its inputs — with or without a `for:` rendering the list. Such a binding is read like a getter, through a non-enumerable accessor added to the state object, and like a getter it is re-evaluated when any row of the list is written; a `$watch` key such as `"users.0.name"` can therefore fire with an unchanged value. An index with no row behind it (an empty list) reads as empty, and a key under an object that is not an array (`sales.2024.total`) reads as a plain key. Through 3.3.0, such a binding kept its first value and followed only replacements of the whole list, and a row getter spelled this way rendered empty with a `wcs/binding-path-missing` warning. A path with two or more numeric indexes (`groups.0.items.1.v`) still behaves that way, as does any numeric-index binding on a state object that cannot be extended (`Object.freeze`, `Object.seal`, `Object.preventExtensions`).
|
|
1058
1058
|
|
|
1059
1059
|
### Getters must be pure with respect to state
|
|
1060
1060
|
|
|
@@ -1131,6 +1131,7 @@ What reaches the rows:
|
|
|
1131
1131
|
- **A write to `path`** — of any value, objects included (`this.selected = row` with `$eq("selected", this["items.*"])`): the row that was selected and the row that becomes selected.
|
|
1132
1132
|
- **A write to an object above `path`** — `this.sel = { id: 2 }` for `$eq("sel.id", …)`: the same two rows, keyed by the value `path` had under the old object and has under the new one.
|
|
1133
1133
|
- **A `path` that is a getter, or sits under one** (`$eq("current.id", …)` with `get current()`): its value changes without a write to it, so the calls fall back to an ordinary tracked read. The selection stays correct, but a change re-evaluates every row, as without the keyed form. Point `path` at the written state (`selectedId`) to keep the two-row cost. The State pane of `@wcstack/devtools` counts the subscriptions per path under **Keyed selection** and marks a path that fell back this way with a `tracked` badge (3.0).
|
|
1134
|
+
- **A `path` with a numeric index** (`$eq("items.0.v", …)`): a write reports the index as a wildcard (`items.*.v`, `items.*`), so a subscription under the spelled path would never hear it; these calls fall back to a tracked read too, and follow a write to that row's `v`, a replacement of the element and of the list (a write to another row's `v` also re-evaluates them, as with any getter). Before this fix such a getter kept its first value unless a binding on the same numeric path existed.
|
|
1134
1135
|
- **No type conversion:** `"2"` does not match the id `2`. An `<input>` or `<select>` writes strings, so convert on the way in (`value|number: selectedId`) or keep the ids as strings.
|
|
1135
1136
|
- **Inside a scope, `path` is relative** — like every other `$` API that takes a path. In a mounted component (`bind-component`) and in a volume (`<wcs-state mount="cart">`), `$eq("selectedId", …)`, `$eqPath`'s two paths, `$eqIndex` and `$dependOn` all resolve against that scope (`cart.selectedId`), not against the root. `$untracked` takes a callback, so nothing is translated there.
|
|
1136
1137
|
|
|
@@ -1280,6 +1281,12 @@ export default {
|
|
|
1280
1281
|
|
|
1281
1282
|
Swapping rows this way moves the rendered rows with their values once the swap is complete: the row blocks are reordered rather than rewritten in place, so a row's `$1` and any state it holds outside bindings (such as text typed into an unbound input) follow the value. Writing a value that was not in the list replaces that row in place: its block stays where it is and its bindings show the new value, so an input bound to the row keeps focus while you type. In a list of primitives, equal values cannot be told apart, so writes that end in a reordering of the same values count as a swap.
|
|
1282
1283
|
|
|
1284
|
+
The swap is complete once every value you moved has left the position it came from. Until then — between the two writes, while the moved value sits at both positions — the rows keep their blocks and show the values by position, and a numeric index (`this["items.0"]`) reads and writes that position. The two writes may also run in separate updates (two clicks, say): the update that completes the swap reports to a `$watch` on `items.*` only the position it wrote, with that position's `prev`. Assigning an array before the swap completes — even an array the path held before — renders that array's values. A list that holds equal values in separate rows swaps like any other, but a write that copies a value another row keeps holding cannot be told apart from the first half of a swap. So once your code leaves rows holding equal values for good, its index writes into that array are rendered by position until the array is replaced: the values and `$1` are right, but the blocks stop following the values.
|
|
1285
|
+
|
|
1286
|
+
None of this applies to a value that an element inside a row writes to its own row — an input bound with `value: .`, or a wc-bindable output bound with `status: .`. That write updates the row's value and never counts as part of a swap: the row keeps its block and its `$1`, and the element's later writes keep landing on its own row, even when several rows report the same value or one row's element writes a value another row holds.
|
|
1287
|
+
|
|
1288
|
+
**One object at two positions.** When the same object sits at two positions of a list (`items: [o, o, …]`), a write to a key below one of those rows (`this["items.0.name"] = "z"`) changes the object, but the other row keeps showing and reading the old value: values below a row are cached per row, and a write reaches only the row it was made through. Call `$postUpdate("items")` after such a write, and every row — the `for:` rows, `$getAll("items.*.name")` and index paths such as `items.1.name` — reads the object again.
|
|
1289
|
+
|
|
1283
1290
|
## Recursive Paths (`$recursion`)
|
|
1284
1291
|
|
|
1285
1292
|
A path burns its depth into the string. `nodes.*.children.*.total` has exactly two wildcard levels, and nothing about it stretches to three when the tree grows a level — but a tree's depth belongs to the data, not to the code. `$recursion` closes that gap: declare where the shape repeats, then write `**` for "however deep this is".
|
|
@@ -1429,7 +1436,9 @@ The read-only rule does not depend on spelling `**`. A recursive getter's concre
|
|
|
1429
1436
|
|
|
1430
1437
|
The walk descends by depth and checks the shape it needs as it goes: reaching the **same array instance** twice is refused. If that array belongs to one of the current node's ancestors it is a cycle (`wcs/recursion-cycle`); otherwise two nodes share one child list (`wcs/recursion-shared-list`). Give every node its own `children` array — sharing an *empty* one is fine and untracked, because it has no rows to alias.
|
|
1431
1438
|
|
|
1432
|
-
Replacing a row object while keeping its `children` array — `this.nodes = this.nodes.map(n => ({ ...n }))` — is an ordinary update, and the aggregates follow it. The child list keeps its existing row objects — so anything keyed by row identity, such as a `bind-component` child scope's rendered rows and any state you have not bound there, survives — and only the retired row they hung under is swapped for the live one, so the next leaf update dirties the row that is actually on screen ([#256](https://github.com/wcstack/wcstack/issues/256)). Two rows *sharing* one `children` array is a different thing. While both rows are in the list it is unchanged: an array has one set of rows, so both rows always agree on every value, and a row getter that reads its parent (`this["nodes.*.value"]`) is evaluated in the context of the row that owns those rows — the one that first expanded the array. What changed is what happens when that owner is removed from the list: the rows follow one of the rows still
|
|
1439
|
+
Replacing a row object while keeping its `children` array — `this.nodes = this.nodes.map(n => ({ ...n }))` — is an ordinary update, and the aggregates follow it. The child list keeps its existing row objects — so anything keyed by row identity, such as a `bind-component` child scope's rendered rows and any state you have not bound there, survives — and only the retired row they hung under is swapped for the live one, so the next leaf update dirties the row that is actually on screen ([#256](https://github.com/wcstack/wcstack/issues/256)). Two rows *sharing* one `children` array is a different thing. While both rows are in the list it is unchanged: an array has one set of rows, so both rows always agree on every value, and a row getter that reads its parent (`this["nodes.*.value"]`) is evaluated in the context of the row that owns those rows — the one that first expanded the array. What changed is what happens when that owner is removed from the list: the rows follow one of the rows still in the list (it can be a row that `if:` hides), so that row's aggregate tracks the shared data instead of freezing at the removed row's numbers — an array still has one set of rows, so with three rows sharing one array a single survivor follows and the others stay frozen. Since 3.4.0 the same hand-over happens when the owner stays in the list but stops holding the array (`this["nodes.0.children"] = [ … ]`): the rows go to another row that holds it under the same key, and that row's aggregate tracks the shared data — 3.3.0 left it frozen. Putting the removed row back hands them straight back to it when that row's own object comes back — whether you reassign the same array instance, build a new array holding the same rows, or put the row back at a different position. When every row is rebuilt instead (`this.nodes = this.nodes.map(n => ({ ...n }))`), no row object matches and the rows end up under whichever row now occupies the owner's old position. Only the same-array-instance restore behaves this way on 2.3.0; restoring with a new array leaves both rows frozen there. Give every node its own array when a child getter reads upward: after the owner stops holding the array, such a getter can keep returning values computed for the old owner until something it reads changes.
|
|
1440
|
+
|
|
1441
|
+
Three sharing shapes are known limits. When different outer rows hold the same array under **different keys** (one as `items`, another as `alt`), a write through one key can land on another element ([#396](https://github.com/wcstack/wcstack/issues/396)). Do not share one array between two `<wcs-state>` elements, a root and its volume included: after a copy or a rebuild in one, a write through the other can land on the wrong array, or throw and be lost ([#397](https://github.com/wcstack/wcstack/issues/397)). And stacking copies, moves and rebuilds of shared arrays in one update can leave some `for:` blocks showing old values while the state is right ([#398](https://github.com/wcstack/wcstack/issues/398)).
|
|
1433
1442
|
|
|
1434
1443
|
The ceiling is **128 wildcard levels** on the expanded path. The aggregate above reads one level below the node it is evaluating, so it folds a chain 127 deep and stops at 128 with `wcs/recursion-depth-exceeded`, naming the anchor, the depth reached, the path it was building, and the limit. That check trips before the getter stack's own 128-frame limit (`wcs/getter-depth-exceeded`), so a deep tree is reported as deep instead of being accused of a cycle. Nothing is truncated on the way: a partial aggregate would be a wrong number reported as a right one.
|
|
1435
1444
|
|
|
@@ -1735,6 +1744,7 @@ customElements.define("user-card", UserCard);
|
|
|
1735
1744
|
- A partial mount can sit next to it: `state: user; state.theme: theme` mounts `theme` as a second entry point (longest prefix wins, so `theme.mode` inside the component reads the tree's `theme.mode`).
|
|
1736
1745
|
- In a loop, mount **the row itself**: `<template data-wcs="for: users"><user-row data-wcs="state: ."></user-row></template>`. Inside the row component `name` is `users.*.name`, and its own `for: tags` runs over `users.*.tags.*`.
|
|
1737
1746
|
- **Own keys are private** (rule R1 in [docs/state-mount-design.md](../../docs/state-mount-design.md) §4-3): a data key the component declares itself (`state = { mode: "view" }`) belongs to that element and is never written to the tree. If it hides a key that exists at the mount point (`state = { name: "" }` mounted over `user.name`), the runtime warns once (`wcs/mount-own-key-shadow`) — remove the default to read the tree, or rename it to keep it private.
|
|
1747
|
+
- A method taken from `element.state` (`card.state.toggle()`) runs as it does from an event binding (`onclick: toggle`): writes to own keys and to tree keys re-render, a row mount's call lands on its own row, and a synchronous method returns its value while an async one returns its Promise.
|
|
1738
1748
|
- Mounting an array as the root (`state: rows` with `for` over it inside) is not supported; mount the row (`state: .`) or the object that holds the array (`state: group` with `for: children` inside). Both forms are contract-tested; mounts are the only way to extend the tree.
|
|
1739
1749
|
|
|
1740
1750
|
> The per-property form (`state.message: user.name`) keeps working — it is a partial mount on
|
|
@@ -1828,6 +1838,8 @@ customElements.define("my-component", MyComponent);
|
|
|
1828
1838
|
</template>
|
|
1829
1839
|
```
|
|
1830
1840
|
|
|
1841
|
+
A component in a row reads and writes its own row. The `this` of its `$connectedCallback` / `$disconnectedCallback` — and a `this` kept from them in a timer or across an `await` — follows the element's row: it keeps writing to that row while an `if:` hides the element, and moves to the new row when an element write (`users[1] = …`) replaces the row in place. Once the element has been taken out and reused for another row, a kept `this` throws `The host row of <user-row> was removed.` instead of writing to either row. When the row itself is removed, `$disconnectedCallback` can still read and write the component's own keys (clear the timer id it kept in `this.tid`); reading the row's keys (`this.name`) throws the same error. A partial mount (`state.name: .name`) keeps one set of own keys per element, not per row, so what its `$disconnectedCallback` writes there is still there when the element is reused for another row.
|
|
1842
|
+
|
|
1831
1843
|
### Rendering a List Inside the Component
|
|
1832
1844
|
|
|
1833
1845
|
An array can be bound into a component and iterated with `for:` **inside** it. The outer state
|
|
@@ -2371,9 +2383,9 @@ Key rules:
|
|
|
2371
2383
|
- **Intermediate values are not observable** — a batch that goes `a → b → c` fires once with `cur = c`, `prev = a`, the same contract as binding updates.
|
|
2372
2384
|
- **Rows follow the list as it stands at the drain** — a row written and then removed, replaced or cut off in the same job does not fire, a row that only moved into another position does not fire, and each position fires at most once. Replacing a nested list fires for every row of the new array.
|
|
2373
2385
|
- **Row-level diffs want `$listKeys`** — without it, assigning a whole array fires the row watch for *every* row with `prev === undefined`, because no row went through a path write. With `$listKeys` declared, the key match decomposes the assignment into per-field writes, so only changed rows fire and `prev` is a real scalar.
|
|
2374
|
-
- **A headless row watch requires `$listKeys`** — this is the one place `$watch` is *not* headless on its own. Expanding `items` into `items.*.price` is driven by the list's `for` binding, and declaring a watch deliberately does not register the path as a list. So with neither a `for` binding nor `$listKeys`, assigning the array fires the row watch **zero** times. Add `$listKeys` (the key match writes each field by path, bypassing the expansion) or render the list. Scalar paths — including nested ones like `user.name` — are headless with no such condition.
|
|
2386
|
+
- **A headless row watch requires `$listKeys`** — this is the one place `$watch` is *not* headless on its own. Expanding `items` into `items.*.price` is driven by the list's `for` binding, and declaring a watch deliberately does not register the path as a list. So with neither a `for` binding nor `$listKeys`, assigning the array fires the row watch **zero** times. Add `$listKeys` (the key match writes each field by path, bypassing the expansion) or render the list. Scalar paths — including nested ones like `user.name` — are headless with no such condition. Without a `for`, reassigning the same array or a copy of it and `$postUpdate("items")` do not fire it either: they only make the row values be read again, so a getter over them (`$getAll("items.*.price", [])`) sees values changed in place.
|
|
2375
2387
|
- **Handler exceptions are isolated** — a throw is reported to the console and the remaining watches (and stream restarts) still run. This differs from `$connectedCallback` / `$renderedCallback`, which fail loudly.
|
|
2376
|
-
- **Write chains are bounded** — a handler's writes form a new batch, so mutually-writing watches would loop forever; the chain is cut off after 32 links with a console error. Values and DOM are not rolled back.
|
|
2388
|
+
- **Write chains are bounded** — a handler's writes form a new batch, so mutually-writing watches would loop forever; the chain is cut off after 32 links with a console error. Values and DOM are not rolled back. Links are counted per write: a handler (or a `$scan` fold) starts from the depth of the write that triggered it, so only a handler's write that triggers another handler extends the chain. A handler that follows a long finite render chain — `$renderedCallback` shrinking a heading one step per render — is not cut off, even when its own writes ride in the same batches. A `$stream` restart counts like a handler: its writes (the reset to `initial`, the status) continue the chain of the write that triggered it, and a restart triggered by a write past the limit is not run. So a `$watch` that writes a stream's `args` and is woken again by the restart is cut off, and so are streams whose `args` read each other — the batch past the limit is reported even when no `$watch` is declared.
|
|
2377
2389
|
- **Not run on a mounted `bind-component` scope** — mounted components do not execute declaration surfaces: the `$watch` declaration is ignored with a one-time console warning that points to the root state (or a volume — `<wcs-state mount>` hosts `$watch` / `$listKeys` / `$renderedCallback`). This applies to `$stream` too. A plain (unwired Shadow) child owns an independent tree and can declare it.
|
|
2378
2390
|
- **SSR does not run watches** — handler side effects would otherwise execute on both server and client.
|
|
2379
2391
|
|
|
@@ -2812,6 +2824,20 @@ If applying a binding throws, the rest of that batch, `$renderedCallback`, `$wat
|
|
|
2812
2824
|
|
|
2813
2825
|
Without that confinement, a single throw left "new values, half-updated DOM" behind, and silently dropped every `$watch` handler and stream restart for the batch — quietly breaking the firing-order contract documented above.
|
|
2814
2826
|
|
|
2827
|
+
### A write made while rendering cannot loop forever
|
|
2828
|
+
|
|
2829
|
+
Some writes happen while bindings are being applied: a wc-bindable element in a new `for:` row hands its initial value to the state (output-only members, `#init=element`), and `$renderedCallback` may write. Each such write starts a new batch, and that batch renders again. If the rendering writes something new every time — say a row element whose output is bound to the very key the list's getter reads, returning a different value per instance — the page would spin in microtasks and freeze. The chain is cut off after 100 links: the batch that would be the 101st is not rendered (its values stay in the state), and one report names the paths it carried:
|
|
2830
|
+
|
|
2831
|
+
```
|
|
2832
|
+
[@wcstack/state] render chain depth limit exceeded; bindings for this batch were not applied. { maxDepth: 100, paths: ["mode", "view", "view.*", "view.*.m"] }
|
|
2833
|
+
```
|
|
2834
|
+
|
|
2835
|
+
The limit is higher than the other two limits (32) because each link here is a whole render. A chain that renders, measures and adjusts can legitimately run past 32 links and still settle — shrinking a heading 1px at a time from 64px to 18px renders 47 times, and drawing 400 rows 10 at a time is 40 links.
|
|
2836
|
+
|
|
2837
|
+
Only writes made synchronously while bindings are being applied extend the chain — including an event that an element dispatches synchronously while a binding sets one of its properties. A write that arrives from outside the drain starts the count again from zero: a user action, an I/O node event that arrives asynchronously, a `$stream` value, an `await` continuation. Writes from `$scan`, `$watch` and `$stream` restarts neither extend nor reset it — they carry the chain over to the batch they start. So a cycle that goes through `$watch` or `$scan` (an element writes `x`, `$watch: { x(v) { this.mode = v } }`, and the list reads `mode`) is cut off too, one link per render; the `$watch` limit does not catch it, because the handler's write comes back through a render rather than straight into another handler. A write-back that settles, such as the initial sync of an ordinary list's rows, adds one link and stops. Because the chain is carried over, finite chains joined by `$watch` or `$scan` share the 100 links: a heading fit that takes 47 links, followed by a second one that a `$watch` starts when the first finishes, settles (94 renders), but a third one joined the same way is cut off partway. The chain is reported once: when the batch that was not rendered still hands the chain on through `$watch` or `$scan`, the batches that follow are not rendered either, without another report. The next write from outside renders normally. DevTools receives `state:render-chain-limit`.
|
|
2838
|
+
|
|
2839
|
+
A loop whose closing write arrives from a microtask is outside this limit, as it was before the limit existed: an element that reports its value from a microtask (Lit's `updated()`, for example) and an async `$renderedCallback` that writes after an `await`. Such a write cannot be told apart from a finite async loop — an `async` function that writes 10,000 times with an `await` of a resolved promise between the writes also renders 10,000 times without a single macrotask — so a limit on renders in a row would stop legitimate code as well.
|
|
2840
|
+
|
|
2815
2841
|
### Values and the DOM are never rolled back
|
|
2816
2842
|
|
|
2817
2843
|
Every failure mode reports and continues; nothing already applied is reverted:
|
|
@@ -2819,7 +2845,8 @@ Every failure mode reports and continues; nothing already applied is reverted:
|
|
|
2819
2845
|
| Mechanism | Limit | On exceeding |
|
|
2820
2846
|
|---|---|---|
|
|
2821
2847
|
| Propagation hops | 32 | Quarantine the transaction's remaining records |
|
|
2822
|
-
| `$watch` write chain | 32 | Skip watch firing for that batch |
|
|
2848
|
+
| `$watch` write chain | 32 | Skip `$scan` / `$watch` firing for that batch, and the `$stream` restarts its over-limit writes would trigger |
|
|
2849
|
+
| Render write chain | 100 | Skip applying bindings for that batch |
|
|
2823
2850
|
| Binding apply failure | — | Skip that one binding |
|
|
2824
2851
|
|
|
2825
2852
|
## Configuration
|
|
@@ -3236,7 +3263,7 @@ That's it. The client-side `@wcstack/state` automatically detects the `<wcs-ssr>
|
|
|
3236
3263
|
|-------|-------------|
|
|
3237
3264
|
| **Server** | `renderToString()` runs your template in happy-dom, executes `$connectedCallback` (including `fetch()`), applies all bindings, and outputs rendered HTML with a `<wcs-ssr>` element containing hydration data |
|
|
3238
3265
|
| **Client** | `<wcs-state enable-ssr>` loads state from `<wcs-ssr>` JSON, skips `$connectedCallback`, and `hydrateBindings()` wires up reactivity on the existing DOM |
|
|
3239
|
-
| **Fallback** | If server/client versions mismatch, the SSR DOM is cleaned up and `buildBindings()` runs a full client-side render |
|
|
3266
|
+
| **Fallback** | If server/client versions mismatch, or a server-rendered `for:` row sits inside another `for:` / `if:` / `elseif:` / `else:` block (hydrating those is a known limitation), the SSR DOM is cleaned up and `buildBindings()` runs a full client-side render. Either case logs one `console.warn` saying why |
|
|
3240
3267
|
|
|
3241
3268
|
### What `enable-ssr` Does
|
|
3242
3269
|
|