@wcstack/state 1.21.7 → 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` が読む)は正当です。
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.
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);
@@ -4026,6 +4042,8 @@ class BindingSession {
4026
4042
  initialPolicy: slot.policy,
4027
4043
  resolvedAuthority: slot.authority,
4028
4044
  initialSettled: true,
4045
+ initialApplyDone: false,
4046
+ outputOnlyMember: slot.policy.outputOnly,
4029
4047
  observationPending: false,
4030
4048
  eventSequence: 0,
4031
4049
  hasProducerValue: false,
@@ -4079,6 +4097,8 @@ class BindingSession {
4079
4097
  record.initialPolicy = slot.policy;
4080
4098
  record.resolvedAuthority = slot.authority;
4081
4099
  record.initialSettled = true;
4100
+ record.initialApplyDone = false;
4101
+ record.outputOnlyMember = slot.policy.outputOnly;
4082
4102
  this.records.add(record);
4083
4103
  if (slot.isEvent) {
4084
4104
  try {
@@ -4137,6 +4157,8 @@ class BindingSession {
4137
4157
  initialPolicy: null,
4138
4158
  resolvedAuthority: null,
4139
4159
  initialSettled: false,
4160
+ initialApplyDone: false,
4161
+ outputOnlyMember: false,
4140
4162
  observationPending: false,
4141
4163
  eventSequence: 0,
4142
4164
  hasProducerValue: false,
@@ -4265,6 +4287,7 @@ class BindingSession {
4265
4287
  record.initialPolicy = policy;
4266
4288
  record.resolvedAuthority = authority;
4267
4289
  record.initialSettled = true;
4290
+ record.outputOnlyMember = policy.outputOnly;
4268
4291
  record.phase = "active";
4269
4292
  if (!policy.observable)
4270
4293
  return;
@@ -6873,7 +6896,7 @@ async function buildBindings(root) {
6873
6896
  }
6874
6897
  }
6875
6898
 
6876
- var version = "1.21.7";
6899
+ var version = "1.22.0";
6877
6900
  var pkg = {
6878
6901
  version: version};
6879
6902
 
@@ -8858,7 +8881,11 @@ function traceArgs(stateElement, entry) {
8858
8881
  * Rescue levels on abort:
8859
8882
  * - ReadableStream: FULLY rescued. A parked read() is force-unwound via
8860
8883
  * reader.cancel(), which both releases the underlying source and settles the
8861
- * 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.
8862
8889
  * - AsyncIterable / async generator: PARTIALLY rescued. On abort we call
8863
8890
  * iterator.return() to trigger the generator's finally/cleanup. But a parked
8864
8891
  * `await` (the producer stalling before its next yield while IGNORING `signal`)
@@ -8939,19 +8966,24 @@ async function consumeSource(source, args, signal, sink) {
8939
8966
  }
8940
8967
  }
8941
8968
  function iterate(produced, signal) {
8942
- // Optional chaining: a null/undefined source return value must fall through to the
8943
- // explicit TypeError below (symmetric with the `?.` on the getReader probe), not
8944
- // 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
+ }
8945
8983
  if (typeof produced?.[Symbol.asyncIterator] === "function") {
8946
8984
  return produced;
8947
8985
  }
8948
- // Not async-iterable: must be a ReadableStream (read via getReader). Validate so
8949
- // a wrong source value yields a clear error instead of an opaque "getReader is
8950
- // not a function" from inside the generator.
8951
- if (typeof produced?.getReader !== "function") {
8952
- throw new TypeError("[@wcstack/state] $streams: source must return an AsyncIterable or a ReadableStream (got neither).");
8953
- }
8954
- return readableToAsyncIterable(produced, signal);
8986
+ throw new TypeError("[@wcstack/state] $streams: source must return an AsyncIterable or a ReadableStream (got neither).");
8955
8987
  }
8956
8988
  async function* readableToAsyncIterable(stream, signal) {
8957
8989
  const reader = stream.getReader();