@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 +80 -6
- package/README.md +83 -6
- package/dist/index.esm.js +61 -20
- package/dist/index.esm.js.map +1 -1
- package/dist/index.esm.min.js +1 -1
- package/dist/index.esm.min.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
-
| 値 |
|
|
360
|
+
| 値 | 初期同期 | 使える宣言 |
|
|
361
361
|
|---|---|---|
|
|
362
|
-
| `init=state` | state
|
|
363
|
-
| `init=element` |
|
|
362
|
+
| `init=state` | state の値を要素へ書く(双方向の既定) | inputs のみ・双方向 |
|
|
363
|
+
| `init=element` | 要素のスナップショットを state へ入れる — 双方向メンバではその後は通常の双方向に戻る(次の変更から state→element も流れる) | output-only・双方向 |
|
|
364
364
|
| `init=auto` | state スロットが未初期化なら `element`、それ以外は `state` | 双方向 |
|
|
365
|
-
| `init=none` |
|
|
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
|
|
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 |
|
|
360
|
+
| Value | Initial sync | Allowed on |
|
|
361
361
|
|---|---|---|
|
|
362
|
-
| `init=state` |
|
|
363
|
-
| `init=element` |
|
|
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
|
-
|
|
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
|
|
3853
|
-
* もろとも GC で崩壊する(recordByBinding 以下は全て弱参照)。
|
|
3868
|
+
* content 専用)。イベント listener・loopContext・パターン台帳(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.
|
|
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
|
-
//
|
|
8934
|
-
//
|
|
8935
|
-
//
|
|
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
|
-
|
|
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();
|