@wcstack/state 1.21.6 → 1.22.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
@@ -344,7 +344,7 @@ property[#modifier]: path[@state][|filter[|filter(args)...]]
344
344
 
345
345
  ### バインディング authority (`#init=` / `#sync=`)
346
346
 
347
- `static wcBindable` を宣言したカスタム要素への prop バインディングは、**authority**(そのワイヤをどちら側が所有するか)を解決します。authority は初期値の出所だけでなく、**そのバインディングの生存期間全体で state→element 書き込みが有効かどうか**を決めます。既定はメンバの宣言位置から導出されます(`enableDirectionalInitialSync` で既定 ON):
347
+ `static wcBindable` を宣言したカスタム要素への prop バインディングは、**authority**(バインディング attach 時の**初期同期**をどちら側が勝つか)を解決します。定常時の方向は authority とは別に、メンバの宣言形状で決まります: output-only メンバは state からの書き込みを恒久的に受け付けず(契約)、双方向メンバは初期同期の勝者と無関係に以後は両方向に流れます。既定 authority はメンバの宣言位置から導出されます(`enableDirectionalInitialSync` で既定 ON):
348
348
 
349
349
  | メンバの宣言位置 | 既定 authority | 効果 |
350
350
  |---|---|---|
@@ -357,12 +357,19 @@ property[#modifier]: path[@state][|filter[|filter(args)...]]
357
357
 
358
358
  authority はバインディング単位で `#init=` により上書きできます:
359
359
 
360
- | 値 | authority | 使える宣言 |
360
+ | 値 | 初期同期 | 使える宣言 |
361
361
  |---|---|---|
362
- | `init=state` | state 所有:state → element(双方向メンバは要素イベントも引き続き受信) | inputs のみ・双方向 |
363
- | `init=element` | element 所有:要素のスナップショットとイベント state。state からの書き込みは抑止 | output-only・双方向 |
362
+ | `init=state` | state の値を要素へ書く(双方向の既定) | inputs のみ・双方向 |
363
+ | `init=element` | 要素のスナップショットを state へ入れる — 双方向メンバではその後は通常の双方向に戻る(次の変更から state→element も流れる) | output-only・双方向 |
364
364
  | `init=auto` | state スロットが未初期化なら `element`、それ以外は `state` | 双方向 |
365
- | `init=none` | 初期同期なし(event バインディングはこの値のみ許可) | すべて |
365
+ | `init=none` | 初期同期なし — 次の変更から通常どおり流れる(event バインディングはこの値のみ許可) | すべて |
366
+
367
+ `#init=` が決めるのは初期競合の勝者だけです。state→element 書き込みの**恒久**抑止は「メンバが output-only 宣言であること」から来るのであって、修飾子からは来ません。これにより `#init=element`(または `#init=auto`)が **load-before-bind** の宣言的な解になります: 要素が自身の `connectedCallback` で永続値をロード済み(バインディング確立より先)でも state 初期値に潰されず、以後の state 変更は要素へ届きます(例えば `<wcs-storage>` の保存は生き続けます):
368
+
369
+ ```html
370
+ <!-- 永続化済みリストが todos を初期化し、以後の todos 代入は保存もされる -->
371
+ <wcs-storage key="todos" type="local" data-wcs="value#init=element: todos"></wcs-storage>
372
+ ```
366
373
 
367
374
  `#sync=` は element authority のバインディングで要素スナップショットを**いつ**読むかを制御します:
368
375
 
@@ -377,10 +384,13 @@ authority はバインディング単位で `#init=` により上書きできま
377
384
  <x-widget data-wcs="value#init=element,sync=connect: widget.snapshot"></x-widget>
378
385
  ```
379
386
 
387
+ `sync=connect` では、接続時スナップショットが初期競合を解決するまで state→element 書き込みも保留されます。
388
+
380
389
  注意:
381
390
 
382
391
  - `enableDirectionalInitialSync: false`(opt-out)のとき `#init=`/`#sync=` を書くと throw します。
383
392
  - **1.20 以前からの移行:** output-only メンバに対して state 側に都合のよい初期値(`value: []`、`query: ""` 等)をシードしないでください — 要素側の実初期値(多くは `null`/`undefined`)がシードを置き換えます。シードは要素の実初期値に合わせ、表示用の値は派生 getter で null ガードしてください。
393
+ - **1.21.x まで**、`init=element` / `init=auto` / `init=none` はバインディングの生存期間全体で state→element 書き込みを抑止しており、真に双方向なメンバには使えませんでした。現在は authority は初期同期のみを支配します(`docs/architecture-hardening/09-remediation-design.md` §3.6)。
384
394
 
385
395
  ### ラジオボタンバインディング
386
396
 
@@ -1485,9 +1495,32 @@ stream の値は普通のプロパティで、コンパニオンの status / err
1485
1495
 
1486
1496
  error 時、プロパティは直前の fold 結果を保持し、エラーは `$streamError.<name>` に入ります。`done` / `error` の stream も依存の変化で restart します(再試行 = 依存の叩き直し)。
1487
1497
 
1498
+ **イベント API の橋渡し** —— 実際の source の多くはコールバック型(`EventSource`・`WebSocket`・DOM イベント)で、async iterable ではありません。標準の `ReadableStream` で包んでください: `start` で enqueue し、`cancel` でリソースを解放します。`AbortSignal` には一切触れません —— restart / 破棄時は runtime が reader を cancel し、parked read を強制解放して `cancel()` まで届けます:
1499
+
1500
+ ```js
1501
+ $streams: {
1502
+ metrics: {
1503
+ args: (state) => ({ host: state.host }),
1504
+ source: ({ host }) => {
1505
+ const es = new EventSource(`/api/metrics?host=${host}`);
1506
+ return new ReadableStream({
1507
+ start(controller) {
1508
+ es.addEventListener("metric", (e) => controller.enqueue(JSON.parse(e.data)));
1509
+ },
1510
+ cancel() { es.close(); }, // restart / 破棄時に走る
1511
+ });
1512
+ },
1513
+ fold: (acc, sample) => [...acc, sample].slice(-20),
1514
+ initial: [],
1515
+ },
1516
+ },
1517
+ ```
1518
+
1519
+ 手書きの async generator も producer 側の制御が要るときは引き続き完全サポートですが、abort 救済は*部分的*です: `signal` を無視して `await` で park した generator は外から強制解放できません。イベント API の橋渡しには `ReadableStream` 形を推奨します。
1520
+
1488
1521
  重要な規範:
1489
1522
 
1490
- - **協調キャンセル(MUST)** —— `source` は渡された `AbortSignal` を必ず監視し、発火したら生産を停止すること。
1523
+ - **協調キャンセル(MUST)** —— `source` は渡された `AbortSignal` を必ず監視し、発火したら生産を停止すること。`ReadableStream` の source はこの契約を `cancel()` コールバック経由で自動的に満たします(abort 時に runtime が reader を cancel します)。自前の async iterable だけが `signal` を自分で監視する必要があります。
1491
1524
  - **有界 fold** —— 需要は producer に逆流しません(backpressure は明示的に放棄)。無限 / 長寿命ストリームでは latest・count・last-N(`(acc, chunk) => [...acc.slice(-99), chunk]`)・ウィンドウ集計など有界な fold を使うこと。生の全チャンク累積は有限ストリーム限定。
1492
1525
  - **`args` は同期** —— Promise を返すとエラー。`args` 内での wildcard 読みも拒否されます。
1493
1526
  - **自己依存・相互サイクルの禁止** —— `args` が自 stream の値や status を読むとエラーになります。2 つの stream の相互サイクル(A の `args` が B の値を読み、B の `args` が A の値を読む)は検出されず無限 restart になるため組まないこと。一方向のチェイン(A の値を B の `args` が読む)は正当です。
@@ -1832,6 +1865,47 @@ buildBindings(root)
1832
1865
  - **StateAddress** — PathInfo + ListIndex の組み合わせ
1833
1866
  - **AbsoluteStateAddress** — 状態名 + StateAddress(クロス状態参照用)
1834
1867
 
1868
+ ## パフォーマンス
1869
+
1870
+ リポジトリ同梱の [js-framework-benchmark](https://github.com/krausest/js-framework-benchmark) 流
1871
+ ドライバ(`e2e/bench/jsfb-verify.mjs`・`e2e/bench/memory-profile.mjs`)で、標準の
1872
+ 1,000 / 10,000 行テーブルページを計測(headless Chromium・中央値・両実装を同一
1873
+ セッションで連続実行)。`@wcstack/state` は公式の keyed 判定に合格しつつ、行 DOM
1874
+ を上限 1,000 行の有界プールでリサイクルします。
1875
+
1876
+ | 所要時間(ms・中央値) | `@wcstack/state` | [`@wcstack/signals`](../signals/) |
1877
+ |---|---|---|
1878
+ | 1,000 行の生成 | 25.2 | 9.5 |
1879
+ | 1,000 行の全置換 | 18.8 | 12.5 |
1880
+ | 10,000 行の 10 行ごと更新 | 11.4 | 4.7 |
1881
+ | 行の選択 | 0.1 | 0.4 |
1882
+ | 2 行の入れ替え | 0.9 | 0.4 |
1883
+ | 行の削除 | 2.8 | 0.6 |
1884
+ | 10,000 行への 1,000 行追加 | 48.6 | 14.2 |
1885
+ | 10,000 行のクリア | 54.6 | 52.2 |
1886
+
1887
+ | 強制 GC 後のヒープ(MB) | `@wcstack/state` | `@wcstack/signals` |
1888
+ |---|---|---|
1889
+ | ページ表示直後 | 1.0 | 0.6 |
1890
+ | 1,000 行生成後 | 5.6 | 3.5 |
1891
+ | 1,000 行×5 回置換後 | 6.4 | 3.7 |
1892
+ | 10,000 行生成後 | 35.1 | 18.0 |
1893
+ | 10,000 行生成 + クリア後 | 13.2 | 1.9 |
1894
+
1895
+ 正直な読み方:
1896
+
1897
+ - 対話系の操作(選択 / 入れ替え / 削除)は数ミリ秒以下で、巨大リストのクリアは
1898
+ signals 実装と同等です。
1899
+ - 行の生成・追加は `@wcstack/signals` の約 2.5〜3.5 倍のコストです。これは本
1900
+ パッケージが行ごとに構築する宣言的バインディング台帳の対価で、`data-wcs` の
1901
+ 検査・DevTools の配線表示・SSR ハイドレーションを支えているのは同じ台帳です。
1902
+ 両パッケージは相互運用できるため、ホットなリストだけ signals の `For` で描画
1903
+ し、残りのページは宣言的なまま保てます。
1904
+ - クリア後に残るヒープは、次のリスト生成を安くする有界の行プールです。
1905
+
1906
+ 絶対値は 1 台の開発機での計測です(v1.21.6 + PR#87 の clear リーク修正)。
1907
+ `e2e/bench/` のドライバで手元のハードウェアでも再現できます。
1908
+
1835
1909
  ## サーバーサイドレンダリング
1836
1910
 
1837
1911
  `@wcstack/state` は [`@wcstack/server`](../server/) パッケージと連携して SSR をサポートしています。クライアント用に書いたテンプレートがそのままサーバーでレンダリングされます — 変更不要。
package/README.md CHANGED
@@ -344,7 +344,7 @@ Automatically enabled for:
344
344
 
345
345
  ### Binding Authority (`#init=` / `#sync=`)
346
346
 
347
- For custom elements that declare `static wcBindable`, every prop binding resolves an **authority** — which side owns the wire. The authority decides both where the initial value comes from *and* whether state→element writes are enabled, for the binding's whole lifetime. It is derived from where the member is declared (on by default via `enableDirectionalInitialSync`):
347
+ 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`):
348
348
 
349
349
  | Member declared in | Default authority | Effect |
350
350
  |---|---|---|
@@ -357,12 +357,19 @@ For custom elements that declare `static wcBindable`, every prop binding resolve
357
357
 
358
358
  Override the authority per binding with `#init=`:
359
359
 
360
- | Value | Authority | Allowed on |
360
+ | Value | Initial sync | Allowed on |
361
361
  |---|---|---|
362
- | `init=state` | State-owned: state element (two-way members still receive element events) | inputs-only, two-way |
363
- | `init=element` | Element-owned: element snapshot and events state; state writes are suppressed | output-only, two-way |
362
+ | `init=state` | The state value is written to the element (two-way default) | inputs-only, two-way |
363
+ | `init=element` | The element's snapshot seeds the state instead — on a two-way member the binding then continues as normal two-way (state→element writes flow from the next change on) | output-only, two-way |
364
364
  | `init=auto` | `element` if the state slot is uninitialized, otherwise `state` | two-way |
365
- | `init=none` | No initial sync (event bindings accept only this value) | any |
365
+ | `init=none` | No initial sync — changes flow normally from the next update (event bindings accept only this value) | any |
366
+
367
+ `#init=` decides only who wins the initial race. The *permanent* suppression of state→element writes comes from the member being declared output-only, never from the modifier. This makes `#init=element` (or `#init=auto`) the declarative fix for **load-before-bind**: an element that loads a persisted value in its own `connectedCallback` — before the binding attaches — is no longer clobbered by the state seed, and later state changes still reach the element (so e.g. `<wcs-storage>` keeps saving):
368
+
369
+ ```html
370
+ <!-- The persisted list seeds `todos`; assigning `todos` later still saves. -->
371
+ <wcs-storage key="todos" type="local" data-wcs="value#init=element: todos"></wcs-storage>
372
+ ```
366
373
 
367
374
  `#sync=` controls **when** the element snapshot is read for element-authority bindings:
368
375
 
@@ -377,10 +384,13 @@ Override the authority per binding with `#init=`:
377
384
  <x-widget data-wcs="value#init=element,sync=connect: widget.snapshot"></x-widget>
378
385
  ```
379
386
 
387
+ With `sync=connect`, state→element writes stay suppressed until the connect snapshot has resolved the initial race.
388
+
380
389
  Notes:
381
390
 
382
391
  - With `enableDirectionalInitialSync: false` (opt-out), writing `#init=`/`#sync=` throws.
383
392
  - **Migrating from ≤ 1.20:** do not seed state with placeholder values (`value: []`, `query: ""`) for output-only members — the element's real initial value (often `null`/`undefined`) replaces the seed. Match the seed to the element's actual initial value and null-guard display values with a derived getter.
393
+ - **Until 1.21.x**, `init=element` / `init=auto` / `init=none` suppressed state→element writes for the binding's whole lifetime, which made them unusable on genuinely two-way members. Authority now governs only the initial sync (`docs/architecture-hardening/09-remediation-design.md` §3.6).
384
394
 
385
395
  ### Radio Binding
386
396
 
@@ -1486,9 +1496,32 @@ The stream's value is an ordinary property, and its companion status / error liv
1486
1496
 
1487
1497
  On error the property keeps its last folded value and the error lands in `$streamError.<name>`; a `done` or `error` stream restarts when its dependencies change (retrying = re-hitting the dependency).
1488
1498
 
1499
+ **Bridging an event API** — most real sources are callback-shaped (`EventSource`, `WebSocket`, DOM events), not async iterables. Wrap them in a standard `ReadableStream`: enqueue in `start`, release the resource in `cancel`. You never touch the `AbortSignal` — on restart/dispose the runtime cancels its reader, which force-unwinds a parked read and runs your `cancel()`:
1500
+
1501
+ ```js
1502
+ $streams: {
1503
+ metrics: {
1504
+ args: (state) => ({ host: state.host }),
1505
+ source: ({ host }) => {
1506
+ const es = new EventSource(`/api/metrics?host=${host}`);
1507
+ return new ReadableStream({
1508
+ start(controller) {
1509
+ es.addEventListener("metric", (e) => controller.enqueue(JSON.parse(e.data)));
1510
+ },
1511
+ cancel() { es.close(); }, // runs on restart / dispose
1512
+ });
1513
+ },
1514
+ fold: (acc, sample) => [...acc, sample].slice(-20),
1515
+ initial: [],
1516
+ },
1517
+ },
1518
+ ```
1519
+
1520
+ Hand-rolled async generators remain fully supported when you need producer-side control, but they are only *partially* rescued on abort: a generator parked in an `await` that ignores `signal` cannot be force-unwound from outside. Prefer the `ReadableStream` form for event-API bridging.
1521
+
1489
1522
  Key rules:
1490
1523
 
1491
- - **Cooperative cancellation (MUST)** — `source` must observe the passed `AbortSignal` and stop producing when it fires.
1524
+ - **Cooperative cancellation (MUST)** — `source` must observe the passed `AbortSignal` and stop producing when it fires. A `ReadableStream` source satisfies this automatically through its `cancel()` callback (the runtime cancels the reader on abort); only hand-rolled async iterables need to observe `signal` themselves.
1492
1525
  - **Bounded fold** — demand never flows back to the producer (backpressure is deliberately abandoned). For infinite / long-lived streams use a bounded fold — latest, count, last-N (`(acc, chunk) => [...acc.slice(-99), chunk]`), windowed aggregates. Raw accumulation of every chunk is for finite streams only.
1493
1526
  - **`args` is synchronous** — returning a Promise is an error, and wildcard reads inside `args` are rejected.
1494
1527
  - **No self-dependency, no mutual cycles** — `args` reading the stream's own value or status raises an error. Mutual cycles between two streams (A's `args` reads B's value and vice versa) are not detected and restart forever — do not build them. One-way chains (A's value feeding B's `args`) are legitimate.
@@ -1834,6 +1867,50 @@ Paths like `users.*.name` are decomposed into:
1834
1867
  - **StateAddress** — combination of PathInfo + ListIndex
1835
1868
  - **AbsoluteStateAddress** — state name + StateAddress (for cross-state references)
1836
1869
 
1870
+ ## Performance
1871
+
1872
+ Measured with the repository's [js-framework-benchmark](https://github.com/krausest/js-framework-benchmark)-style
1873
+ drivers (`e2e/bench/jsfb-verify.mjs`, `e2e/bench/memory-profile.mjs`) against the
1874
+ standard 1,000 / 10,000-row table page — headless Chromium, medians, both
1875
+ implementations measured back-to-back in the same session. `@wcstack/state`
1876
+ passes the official keyed-mode classification while recycling row DOM through a
1877
+ bounded pool (up to 1,000 rows).
1878
+
1879
+ | Duration (ms, median) | `@wcstack/state` | [`@wcstack/signals`](../signals/) |
1880
+ |---|---|---|
1881
+ | create 1,000 rows | 25.2 | 9.5 |
1882
+ | replace all 1,000 rows | 18.8 | 12.5 |
1883
+ | update every 10th of 10,000 | 11.4 | 4.7 |
1884
+ | select row | 0.1 | 0.4 |
1885
+ | swap 2 rows | 0.9 | 0.4 |
1886
+ | remove row | 2.8 | 0.6 |
1887
+ | append 1,000 to 10,000 | 48.6 | 14.2 |
1888
+ | clear 10,000 rows | 54.6 | 52.2 |
1889
+
1890
+ | Heap after forced GC (MB) | `@wcstack/state` | `@wcstack/signals` |
1891
+ |---|---|---|
1892
+ | page ready | 1.0 | 0.6 |
1893
+ | after create 1,000 | 5.6 | 3.5 |
1894
+ | after 5× replace 1,000 | 6.4 | 3.7 |
1895
+ | after create 10,000 | 35.1 | 18.0 |
1896
+ | after create 10,000 + clear | 13.2 | 1.9 |
1897
+
1898
+ How to read this, honestly:
1899
+
1900
+ - Interactive operations (select / swap / remove) run in a few milliseconds or
1901
+ less, and clearing a huge list matches the signals implementation.
1902
+ - Creating and appending rows costs ~2.5–3.5× `@wcstack/signals`. That is the
1903
+ price of the declarative binding ledger this package builds per row — the same
1904
+ ledger that powers `data-wcs` inspection, DevTools wiring, and SSR hydration.
1905
+ The two packages interoperate, so a hot list can be rendered with signals'
1906
+ `For` while the rest of the page stays declarative.
1907
+ - The heap retained after a clear is the bounded row pool that makes the next
1908
+ list population cheap.
1909
+
1910
+ Absolute numbers are from one development machine (v1.21.6 + the clear-leak fix
1911
+ in PR#87); the drivers in `e2e/bench/` reproduce the comparison on your own
1912
+ hardware.
1913
+
1837
1914
  ## Server-Side Rendering
1838
1915
 
1839
1916
  `@wcstack/state` supports SSR via the companion [`@wcstack/server`](../server/) package. The same templates you write for the client render on the server — no changes needed.
package/dist/index.esm.js CHANGED
@@ -3397,12 +3397,12 @@ function hasInitialSyncModifier(binding) {
3397
3397
  // 頻出ポリシー(修飾子なしの通常バインディング)の凍結シングルトン。リスト行では
3398
3398
  // binding ごとに resolveInitialSyncPolicy が走るため、毎回のオブジェクト割り当てを
3399
3399
  // 避ける(record.initialPolicy は読み取り専用でしか使われない)。
3400
- const STATE_CALL_POLICY = Object.freeze({ authority: "state", syncOn: "call", observable: false });
3401
- const NONE_CALL_POLICY = Object.freeze({ authority: "none", syncOn: "call", observable: false });
3400
+ const STATE_CALL_POLICY = Object.freeze({ authority: "state", syncOn: "call", observable: false, outputOnly: false });
3401
+ const NONE_CALL_POLICY = Object.freeze({ authority: "none", syncOn: "call", observable: false, outputOnly: false });
3402
3402
  function statePolicy(authority, syncOn) {
3403
3403
  if (authority === "state" && syncOn === "call")
3404
3404
  return STATE_CALL_POLICY;
3405
- return { authority, syncOn, observable: false };
3405
+ return { authority, syncOn, observable: false, outputOnly: false };
3406
3406
  }
3407
3407
  function resolveInitialSyncPolicy(binding) {
3408
3408
  if (!config.enableDirectionalInitialSync) {
@@ -3417,7 +3417,7 @@ function resolveInitialSyncPolicy(binding) {
3417
3417
  if (explicitAuthority !== null && explicitAuthority !== "none") {
3418
3418
  raiseError("Event bindings only allow init=none.");
3419
3419
  }
3420
- return syncOn === "call" ? NONE_CALL_POLICY : { authority: "none", syncOn, observable: false };
3420
+ return syncOn === "call" ? NONE_CALL_POLICY : { authority: "none", syncOn, observable: false, outputOnly: false };
3421
3421
  }
3422
3422
  // command.<name>: $command.<method> は命令的な command-token 配線。bindingType は
3423
3423
  // "prop" だが propName ("command.<name>") は wcBindable property ではないため、下の
@@ -3455,7 +3455,7 @@ function resolveInitialSyncPolicy(binding) {
3455
3455
  if (syncOn === "connect" && !hasOutput) {
3456
3456
  raiseError(`sync=connect requires observable property "${binding.propName}".`);
3457
3457
  }
3458
- return { authority, syncOn, observable: hasOutput };
3458
+ return { authority, syncOn, observable: hasOutput, outputOnly: hasOutput && !hasInput };
3459
3459
  }
3460
3460
  function isBindingStateInitialized(binding) {
3461
3461
  const rootNode = binding.replaceNode.getRootNode();
@@ -3756,9 +3756,25 @@ class BindingSession {
3756
3756
  return true;
3757
3757
  if (!record.options.registerAddress || record.phase === "waiting-definition")
3758
3758
  return true;
3759
+ if (record.phase === "failed")
3760
+ return false;
3759
3761
  if (record.phase === "active")
3760
3762
  this.settleInitialRecord(record);
3761
- return record.resolvedAuthority === "state";
3763
+ // authority は初期同期のみを支配する(09 §3.6: init=element は「snapshot を
3764
+ // state へ入れる」、init=none は「次の変更から扱う」)。settle 後の最初の相談
3765
+ // = 初期 state sweep / 初回 render / deferred initial apply の選別なので
3766
+ // authority で答え、ここで初期適用を消費する。
3767
+ if (!record.initialApplyDone) {
3768
+ record.initialApplyDone = true;
3769
+ return record.resolvedAuthority === "state";
3770
+ }
3771
+ if (record.resolvedAuthority === "state")
3772
+ return true;
3773
+ // 定常: two-way / input member は authority と無関係に state→element を流す。
3774
+ // 恒久ブロックは (1) output-only member の契約(書き込み無意味・DCC/router の
3775
+ // conformance が依存)と (2) sync=connect の接続 snapshot が未解決の間
3776
+ //(初期競合が未決着のうちは state push が element 初期値を潰しうる)だけ。
3777
+ return !record.outputOnlyMember && !record.observationPending;
3762
3778
  }
3763
3779
  getRecord(binding) {
3764
3780
  const record = recordByBinding.get(binding);
@@ -3849,13 +3865,22 @@ class BindingSession {
3849
3865
  }
3850
3866
  /**
3851
3867
  * 全 record を teardown を走らせずに終端化する(canWholesaleDestroy が true の
3852
- * content 専用)。イベント listener・アドレス台帳・loopContext はノード/binding
3853
- * もろとも GC で崩壊する(recordByBinding 以下は全て弱参照)。
3868
+ * content 専用)。イベント listenerloopContext・パターン台帳(listIndex キー)は
3869
+ * ノード/binding もろとも GC で崩壊する(recordByBinding 以下は全て弱参照)。
3870
+ * 例外は null-listIndex の従来台帳(record.address): キーの intern 済み
3871
+ * AbsoluteStateAddress が PathInfo キャッシュ経由で生涯生存するため GC で
3872
+ * 崩壊せず、共有エントリに残った binding が binding.node 経由で行 DOM 全体を
3873
+ * 永久リークする。ここだけ明示除去する(行イベント binding が典型で行あたり
3874
+ * 高々数件・Set.delete のみなので wholesale の速度特性は保たれる)。
3854
3875
  * handlerBindingRegistry のカウンタは減らないが、残るのはキー文字列と数値のみで
3855
3876
  * 実害はない設計(handlerBindingRegistry.ts の弱参照化コメント参照)。
3856
3877
  */
3857
3878
  destroyRecords() {
3858
3879
  for (const record of this.records) {
3880
+ if (record.address !== null) {
3881
+ removeBindingByAbsoluteStateAddress(record.address, record.info);
3882
+ record.address = null;
3883
+ }
3859
3884
  record.phase = "disposed";
3860
3885
  record.teardowns = null;
3861
3886
  }
@@ -4017,6 +4042,8 @@ class BindingSession {
4017
4042
  initialPolicy: slot.policy,
4018
4043
  resolvedAuthority: slot.authority,
4019
4044
  initialSettled: true,
4045
+ initialApplyDone: false,
4046
+ outputOnlyMember: slot.policy.outputOnly,
4020
4047
  observationPending: false,
4021
4048
  eventSequence: 0,
4022
4049
  hasProducerValue: false,
@@ -4070,6 +4097,8 @@ class BindingSession {
4070
4097
  record.initialPolicy = slot.policy;
4071
4098
  record.resolvedAuthority = slot.authority;
4072
4099
  record.initialSettled = true;
4100
+ record.initialApplyDone = false;
4101
+ record.outputOnlyMember = slot.policy.outputOnly;
4073
4102
  this.records.add(record);
4074
4103
  if (slot.isEvent) {
4075
4104
  try {
@@ -4128,6 +4157,8 @@ class BindingSession {
4128
4157
  initialPolicy: null,
4129
4158
  resolvedAuthority: null,
4130
4159
  initialSettled: false,
4160
+ initialApplyDone: false,
4161
+ outputOnlyMember: false,
4131
4162
  observationPending: false,
4132
4163
  eventSequence: 0,
4133
4164
  hasProducerValue: false,
@@ -4256,6 +4287,7 @@ class BindingSession {
4256
4287
  record.initialPolicy = policy;
4257
4288
  record.resolvedAuthority = authority;
4258
4289
  record.initialSettled = true;
4290
+ record.outputOnlyMember = policy.outputOnly;
4259
4291
  record.phase = "active";
4260
4292
  if (!policy.observable)
4261
4293
  return;
@@ -6864,7 +6896,7 @@ async function buildBindings(root) {
6864
6896
  }
6865
6897
  }
6866
6898
 
6867
- var version = "1.21.6";
6899
+ var version = "1.22.0";
6868
6900
  var pkg = {
6869
6901
  version: version};
6870
6902
 
@@ -8849,7 +8881,11 @@ function traceArgs(stateElement, entry) {
8849
8881
  * Rescue levels on abort:
8850
8882
  * - ReadableStream: FULLY rescued. A parked read() is force-unwound via
8851
8883
  * reader.cancel(), which both releases the underlying source and settles the
8852
- * pending read() so the loop unwinds.
8884
+ * pending read() so the loop unwinds. Anything getReader-bearing is routed
8885
+ * through this path even when it is ALSO natively async-iterable (Node 18+ /
8886
+ * Chrome 124+ / Firefox): the spec serializes asyncIterator.return() behind
8887
+ * the pending next() ([[ongoingPromise]]), so the AsyncIterable rescue below
8888
+ * cannot force-unwind a parked read on a native ReadableStream.
8853
8889
  * - AsyncIterable / async generator: PARTIALLY rescued. On abort we call
8854
8890
  * iterator.return() to trigger the generator's finally/cleanup. But a parked
8855
8891
  * `await` (the producer stalling before its next yield while IGNORING `signal`)
@@ -8930,19 +8966,24 @@ async function consumeSource(source, args, signal, sink) {
8930
8966
  }
8931
8967
  }
8932
8968
  function iterate(produced, signal) {
8933
- // Optional chaining: a null/undefined source return value must fall through to the
8934
- // explicit TypeError below (symmetric with the `?.` on the getReader probe), not
8935
- // throw an opaque "Cannot read properties of null" from this property access.
8969
+ // Anything ReadableStream-shaped (has getReader) takes the reader path FIRST
8970
+ // even when it is also async-iterable. On runtimes where ReadableStream
8971
+ // implements native async iteration (Node 18+ / Chrome 124+ / Firefox), the
8972
+ // spec serializes `iterator.return()` behind the pending `next()`
8973
+ // ([[ongoingPromise]]), so the AsyncIterable rescue cannot force-unwind a
8974
+ // parked read: abort would hang the consume task until the producer's next
8975
+ // chunk (or forever on a silent stream), leaking the underlying source.
8976
+ // `reader.cancel()` has no such ordering — routing every getReader-bearing
8977
+ // source through it keeps the FULL rescue promised for ReadableStream.
8978
+ // Optional chaining: a null/undefined source return value must fall through to
8979
+ // the explicit TypeError below, not throw an opaque property access error.
8980
+ if (typeof produced?.getReader === "function") {
8981
+ return readableToAsyncIterable(produced, signal);
8982
+ }
8936
8983
  if (typeof produced?.[Symbol.asyncIterator] === "function") {
8937
8984
  return produced;
8938
8985
  }
8939
- // Not async-iterable: must be a ReadableStream (read via getReader). Validate so
8940
- // a wrong source value yields a clear error instead of an opaque "getReader is
8941
- // not a function" from inside the generator.
8942
- if (typeof produced?.getReader !== "function") {
8943
- throw new TypeError("[@wcstack/state] $streams: source must return an AsyncIterable or a ReadableStream (got neither).");
8944
- }
8945
- return readableToAsyncIterable(produced, signal);
8986
+ throw new TypeError("[@wcstack/state] $streams: source must return an AsyncIterable or a ReadableStream (got neither).");
8946
8987
  }
8947
8988
  async function* readableToAsyncIterable(stream, signal) {
8948
8989
  const reader = stream.getReader();