@wcstack/state 1.23.0 → 1.24.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/dist/index.d.ts CHANGED
@@ -23,7 +23,6 @@ interface ILoopContextStack {
23
23
  createLoopContext(elementStateAddress: IStateAddress, callback: (loopContext: ILoopContext) => void | Promise<void>): void | Promise<void>;
24
24
  }
25
25
 
26
- declare const setLoopContextAsyncSymbol: unique symbol;
27
26
  declare const setLoopContextSymbol: unique symbol;
28
27
  declare const getByAddressSymbol: unique symbol;
29
28
  declare const hasByAddressSymbol: unique symbol;
@@ -33,7 +32,6 @@ declare const disconnectedCallbackSymbol: unique symbol;
33
32
  declare const updatedCallbackSymbol: unique symbol;
34
33
 
35
34
  interface IStateProxy extends IState {
36
- [setLoopContextAsyncSymbol](loopContext: ILoopContext | null, callback: () => Promise<any>): Promise<any>;
37
35
  [setLoopContextSymbol](loopContext: ILoopContext | null, callback: () => any): any;
38
36
  [getByAddressSymbol](address: IStateAddress): any;
39
37
  [hasByAddressSymbol](address: IStateAddress): boolean;
package/dist/index.esm.js CHANGED
@@ -1918,7 +1918,6 @@ function setLastListValueByAbsoluteStateAddress(address, value) {
1918
1918
  lastListValueByAbsoluteStateAddress.set(address, value);
1919
1919
  }
1920
1920
 
1921
- const setLoopContextAsyncSymbol = Symbol("$$setLoopContextAsync");
1922
1921
  const setLoopContextSymbol = Symbol("$$setLoopContext");
1923
1922
  const getByAddressSymbol = Symbol("$$getByAddress");
1924
1923
  const hasByAddressSymbol = Symbol("$$hasByAddress");
@@ -2489,6 +2488,54 @@ function detachCheckboxEventHandler(binding) {
2489
2488
  return false;
2490
2489
  }
2491
2490
 
2491
+ /**
2492
+ * captureHandlerRejection.ts
2493
+ *
2494
+ * state 側ハンドラ(`$on` の event-token subscriber、`onXxx:` の state メソッド、
2495
+ * DOM イベント起点の command-token emit)の戻り値を受け取り、Promise が混ざって
2496
+ * いれば reject を捕捉して報告する。
2497
+ *
2498
+ * なぜ必要か:
2499
+ * 発火経路はハンドラの完了を待たない。戻り値は `Token.emit` の結果配列にしか現れず、
2500
+ * 呼び出し側(eventTokenHandler / handler)はそれを捨てている。そのため **async
2501
+ * ハンドラが reject すると unhandled rejection になり**、しかも「どのハンドラで
2502
+ * 落ちたか」の手掛かりがスタックにしか残らない(特性化:
2503
+ * `__tests__/poc.asyncOnLoopContext.test.ts`)。
2504
+ *
2505
+ * 握り潰しではない:
2506
+ * 可視性は `console.error` で保たれ、state 名・ハンドラ名が付く分むしろ特定は容易に
2507
+ * なる。非同期の失敗を「例外の伝播」ではなく「診断可能な報告」に落とすのは
2508
+ * never-throw(async-io-node-guidelines.md §3.6)と同じ方針であり、I/O ノードが
2509
+ * `error` プロパティへ流すのと同じ位置づけの、state 側ハンドラ版にあたる。
2510
+ *
2511
+ * 同期 throw はここを通らない(従来どおり呼び出し元へ伝播する)。プログラマエラーを
2512
+ * loud に落とす `raiseError` の挙動は一切変えない。
2513
+ */
2514
+ function isThenable(value) {
2515
+ return ((typeof value === "object" || typeof value === "function") &&
2516
+ value !== null &&
2517
+ typeof value.then === "function");
2518
+ }
2519
+ /**
2520
+ * @param result ハンドラ呼び出しの戻り値。`Token.emit` の結果配列(subscriber ごとの
2521
+ * 戻り値)と、単一ハンドラの戻り値の両方を受ける。
2522
+ * @param describe 報告に載せるハンドラの識別名(例: `$on."rowFailed" of state "default"`)。
2523
+ */
2524
+ function captureHandlerRejection(result, describe) {
2525
+ // emit は subscriber ごとの戻り値配列。単一ハンドラの戻り値はそのまま届く。
2526
+ const values = Array.isArray(result) ? result : [result];
2527
+ for (const value of values) {
2528
+ if (!isThenable(value)) {
2529
+ continue;
2530
+ }
2531
+ // Promise.resolve は native Promise をそのまま返すため、catch の登録によって
2532
+ // 元の Promise が handled になる(thenable は同値の Promise に包まれる)。
2533
+ Promise.resolve(value).catch((error) => {
2534
+ console.error(`[wcstack/state] ${describe} rejected.`, error);
2535
+ });
2536
+ }
2537
+ }
2538
+
2492
2539
  // command-token / event-token が共有する pub/sub プリミティブ。
2493
2540
  // _subscribers は Set のため挿入順を保持する。
2494
2541
  // emit() は subscribe() された順に呼び出され、戻り値配列も同じ順序で返る。
@@ -2654,11 +2701,14 @@ function attachEventTokenHandler(binding) {
2654
2701
  }
2655
2702
  const loopContext = getLoopContextByNode(element);
2656
2703
  stateElement.createStateAsync("writable", async (state) => {
2657
- state[setLoopContextSymbol](loopContext, () => {
2704
+ const results = state[setLoopContextSymbol](loopContext, () => {
2658
2705
  const indexes = loopContext?.listIndex.indexes ?? [];
2659
2706
  const token = getOrCreateEventToken(stateElement, tokenName);
2660
2707
  return token.emit(state, event, ...indexes);
2661
2708
  });
2709
+ // この経路はハンドラの完了を待たない(emit の戻り値はここでしか見えない)。
2710
+ // async な $on ハンドラの reject を unhandled にせず報告へ落とす。
2711
+ captureHandlerRejection(results, `$on."${tokenName}" of state "${stateName}"`);
2662
2712
  });
2663
2713
  };
2664
2714
  element.addEventListener(eventName, handler);
@@ -2736,7 +2786,7 @@ const stateEventHandlerFunction = (stateName, handlerName, modifiers, statePathI
2736
2786
  const loopContext = getLoopContextByNode(node);
2737
2787
  const isCommand = isCommandTokenPath(handlerName);
2738
2788
  stateElement.createStateAsync("writable", async (state) => {
2739
- state[setLoopContextSymbol](loopContext, () => {
2789
+ const results = state[setLoopContextSymbol](loopContext, () => {
2740
2790
  const indexes = loopContext?.listIndex.indexes ?? [];
2741
2791
  if (isCommand) {
2742
2792
  // command token を解決して emit。引数はハンドラ呼び出しと同じく (event, ...listIndexes) を透過する。
@@ -2752,6 +2802,9 @@ const stateEventHandlerFunction = (stateName, handlerName, modifiers, statePathI
2752
2802
  }
2753
2803
  return Reflect.apply(handler, state, [event, ...indexes]);
2754
2804
  });
2805
+ // eventTokenHandler と同じく、この経路もハンドラの完了を待たない。async な
2806
+ // state メソッド / command subscriber の reject を unhandled にせず報告へ落とす。
2807
+ captureHandlerRejection(results, `"${handlerName}" of state "${stateName}"`);
2755
2808
  });
2756
2809
  };
2757
2810
  function attachEventHandler(binding) {
@@ -3026,14 +3079,53 @@ function matchWriteReceipt(node, member) {
3026
3079
  return null;
3027
3080
  }
3028
3081
 
3082
+ /**
3083
+ * occurrenceWrite.ts
3084
+ *
3085
+ * wc-bindable の `semantics: "event"` を宣言した property から届いた値を state へ書き込む
3086
+ * 間だけ、same-value guard(`config.sameValueGuard`・既定 ON)を 1 回分だけ無効化する
3087
+ * one-shot トークン。
3088
+ *
3089
+ * 背景:
3090
+ * same-value guard は primitive が `Object.is` 同値なら set / enqueue / 依存伝播 / DOM 適用 /
3091
+ * `$updatedCallback` をまるごとスキップする。current value(state)にとっては正しい最適化だが、
3092
+ * occurrence(同じ payload でも「もう一度起きた」ことに意味がある)へ適用すると発生を取りこぼす。
3093
+ * どちらであるかは producer の declaration が `semantics` で宣言する
3094
+ * (docs/architecture-hardening/12-wc-bindable-observable-inventory.md)。
3095
+ *
3096
+ * one-shot にしている理由:
3097
+ * フラグを書き込みの呼び出しスタック全体へ張ると、その内側で走る `$updatedCallback` や
3098
+ * 依存伝播が行う無関係な書き込みまでガードを失う。`setByAddress` が最初のガード評価で
3099
+ * トークンを消費するため、影響は目的の 1 write に閉じる。
3100
+ */
3101
+ let pending = false;
3102
+ /** 直後の 1 write を occurrence として扱う。必ず `endOccurrenceWrite` と対で使う。 */
3103
+ function beginOccurrenceWrite() {
3104
+ pending = true;
3105
+ }
3106
+ /** 未消費のトークンを破棄する(write が setByAddress へ到達しなかった場合の後始末)。 */
3107
+ function endOccurrenceWrite() {
3108
+ pending = false;
3109
+ }
3110
+ /**
3111
+ * ガード評価側が呼ぶ。`true` を返したら、その 1 回だけ same-value guard を飛ばす。
3112
+ * トークンは呼んだ時点で消費される。
3113
+ */
3114
+ function consumeOccurrenceWrite() {
3115
+ if (!pending)
3116
+ return false;
3117
+ pending = false;
3118
+ return true;
3119
+ }
3120
+
3029
3121
  const handlerByHandlerKey = new Map();
3030
3122
  // binding を強参照しない台帳(handlerBindingRegistry.ts のリーク解説を参照)
3031
3123
  const bindingRegistry = createHandlerBindingRegistry();
3032
3124
  const producerValueObserversByNode = new WeakMap();
3033
3125
  const DEFAULT_GETTER = (e) => e.detail;
3034
- function getHandlerKey(binding, eventName, hasGetter) {
3126
+ function getHandlerKey(binding, eventName, hasGetter, isOccurrence) {
3035
3127
  const filterKey = binding.inFilters.map(f => f.filterName + '(' + f.args.join(',') + ')').join('|');
3036
- return `${binding.stateName}::${binding.propName}::${binding.statePathName}::${eventName}::${filterKey}::${hasGetter ? 'g' : 'n'}`;
3128
+ return `${binding.stateName}::${binding.propName}::${binding.statePathName}::${eventName}::${filterKey}::${hasGetter ? 'g' : 'n'}::${isOccurrence ? 'o' : 's'}`;
3037
3129
  }
3038
3130
  function getEventName(binding) {
3039
3131
  const tagName = binding.node.tagName.toLowerCase();
@@ -3069,7 +3161,20 @@ function getValueGetter(binding) {
3069
3161
  }
3070
3162
  return null;
3071
3163
  }
3072
- const twowayEventHandlerFunction = (stateName, propName, statePathName, inFilters, valueGetter) => (event) => {
3164
+ /**
3165
+ * producer が `semantics: "event"` を宣言した property か。occurrence は同じ payload でも
3166
+ * 「もう一度起きた」ことに意味があるため、state への書き込みで same-value guard を通さない
3167
+ * (docs/async-io-node-guidelines.md §3.3.1 の `event`)。宣言が無い property は従来どおり
3168
+ * — 未指定は「未指定」であって state ではないので、挙動は変えない。
3169
+ */
3170
+ function isOccurrenceProperty(binding) {
3171
+ const customTagName = getCustomElement(binding.node);
3172
+ if (customTagName === null)
3173
+ return false;
3174
+ const propDesc = readBindableDeclaration(binding.node)?.knownProperties.get(binding.propName);
3175
+ return propDesc?.semantics === "event";
3176
+ }
3177
+ const twowayEventHandlerFunction = (stateName, propName, statePathName, inFilters, valueGetter, isOccurrence) => (event) => {
3073
3178
  const node = event.target;
3074
3179
  if (node === null) {
3075
3180
  console.warn(`[@wcstack/state] event.target is null.`);
@@ -3155,11 +3260,21 @@ const twowayEventHandlerFunction = (stateName, propName, statePathName, inFilter
3155
3260
  }
3156
3261
  const loopContext = getLoopContextByNode(node);
3157
3262
  const commitToState = () => {
3158
- stateElement.createState("writable", (state) => {
3159
- state[setLoopContextSymbol](loopContext, () => {
3160
- state[statePathName] = filteredNewValue;
3263
+ // occurrence は同値でも取りこぼしてはならない(§3.3.1 `event`)。トークンは
3264
+ // setByAddress の最初のガード評価で消費されるため、この write 1 回だけに効く。
3265
+ if (isOccurrence)
3266
+ beginOccurrenceWrite();
3267
+ try {
3268
+ stateElement.createState("writable", (state) => {
3269
+ state[setLoopContextSymbol](loopContext, () => {
3270
+ state[statePathName] = filteredNewValue;
3271
+ });
3161
3272
  });
3162
- });
3273
+ }
3274
+ finally {
3275
+ if (isOccurrence)
3276
+ endOccurrenceWrite();
3277
+ }
3163
3278
  };
3164
3279
  if (propagationContext !== null) {
3165
3280
  runWithPropagationContext(propagationContext, commitToState);
@@ -3203,10 +3318,11 @@ function attachTwowayEventHandler(binding) {
3203
3318
  if (isPossibleTwoWay(binding.node, binding.propName) && binding.propModifiers.indexOf('ro') === -1) {
3204
3319
  const eventName = getEventName(binding);
3205
3320
  const valueGetter = getValueGetter(binding);
3206
- const key = getHandlerKey(binding, eventName, valueGetter !== null);
3321
+ const isOccurrence = isOccurrenceProperty(binding);
3322
+ const key = getHandlerKey(binding, eventName, valueGetter !== null, isOccurrence);
3207
3323
  let twowayEventHandler = handlerByHandlerKey.get(key);
3208
3324
  if (typeof twowayEventHandler === "undefined") {
3209
- twowayEventHandler = twowayEventHandlerFunction(binding.stateName, binding.propName, binding.statePathName, binding.inFilters, valueGetter);
3325
+ twowayEventHandler = twowayEventHandlerFunction(binding.stateName, binding.propName, binding.statePathName, binding.inFilters, valueGetter, isOccurrence);
3210
3326
  handlerByHandlerKey.set(key, twowayEventHandler);
3211
3327
  }
3212
3328
  binding.node.addEventListener(eventName, twowayEventHandler);
@@ -3228,7 +3344,7 @@ function detachTwowayEventHandler(binding) {
3228
3344
  if (isPossibleTwoWay(binding.node, binding.propName) && binding.propModifiers.indexOf('ro') === -1) {
3229
3345
  const eventName = getEventName(binding);
3230
3346
  const valueGetter = getValueGetter(binding);
3231
- const key = getHandlerKey(binding, eventName, valueGetter !== null);
3347
+ const key = getHandlerKey(binding, eventName, valueGetter !== null, isOccurrenceProperty(binding));
3232
3348
  const twowayEventHandler = handlerByHandlerKey.get(key);
3233
3349
  if (typeof twowayEventHandler === "undefined") {
3234
3350
  return;
@@ -6986,7 +7102,7 @@ async function buildBindings(root) {
6986
7102
  }
6987
7103
  }
6988
7104
 
6989
- var version = "1.23.0";
7105
+ var version = "1.24.0";
6990
7106
  var pkg = {
6991
7107
  version: version};
6992
7108
 
@@ -10258,6 +10374,10 @@ function _setByAddressWithSwap(target, address, absAddress, value, receiver, han
10258
10374
  function setByAddress(target, address, value, receiver, handler) {
10259
10375
  const stateElement = handler.stateElement;
10260
10376
  const path = address.pathInfo.path;
10377
+ // occurrence(wc-bindable の `semantics: "event"`)由来の書き込みは、同値でも
10378
+ // 「もう一度起きた」ことを落としてはならないため same-value guard を 1 回だけ飛ばす。
10379
+ // トークンはここで消費されるので、この write の内側で走る他の書き込みには波及しない。
10380
+ const skipSameValueGuard = consumeOccurrenceWrite();
10261
10381
  // --- fast path: 宣言済み getter/setter でも swap 対象でもない、親を持つ葉パス ---
10262
10382
  // 従来は same-value guard の値読み・hasByAddress・実書き込みがそれぞれ親チェーンを
10263
10383
  // 解決していた(キャッシュヒットでも getByAddress 呼び出しの固定費 ×3)。
@@ -10274,7 +10394,7 @@ function setByAddress(target, address, value, receiver, handler) {
10274
10394
  : lastSegment;
10275
10395
  let devOldValue;
10276
10396
  let devHasOldValue = false;
10277
- if (config.sameValueGuard && (value === null || typeof value !== "object")) {
10397
+ if (!skipSameValueGuard && config.sameValueGuard && (value === null || typeof value !== "object")) {
10278
10398
  // hasByAddress と同じ「初期化済みスロットか」判定(undefined 格納と未初期化を区別)
10279
10399
  const has = key !== undefined && key in parentValue;
10280
10400
  const oldValue = key !== undefined ? parentValue[key] : undefined;
@@ -10334,7 +10454,7 @@ function setByAddress(target, address, value, receiver, handler) {
10334
10454
  // (参照型のために追加の get はしない — protocol §4.2)
10335
10455
  let devOldValue;
10336
10456
  let devHasOldValue = false;
10337
- if (config.sameValueGuard && (value === null || typeof value !== "object")) {
10457
+ if (!skipSameValueGuard && config.sameValueGuard && (value === null || typeof value !== "object")) {
10338
10458
  const oldValue = getByAddress(target, address, receiver, handler);
10339
10459
  if (hasByAddress(target, address, receiver, handler) && Object.is(oldValue, value)) {
10340
10460
  return true;
@@ -10716,7 +10836,7 @@ function updatedCallback(target, refs, receiver, handler) {
10716
10836
  * setLoopContext.ts
10717
10837
  *
10718
10838
  * StateClassの内部APIとして、ループコンテキスト(ILoopContext)を一時的に設定し、
10719
- * 指定した同期/非同期コールバックをそのスコープ内で実行するための関数です。
10839
+ * 指定したコールバックをそのスコープ内で実行するための関数です。
10720
10840
  *
10721
10841
  * 主な役割:
10722
10842
  * - handler.loopContextにループコンテキストを一時的に設定
@@ -10727,9 +10847,26 @@ function updatedCallback(target, refs, receiver, handler) {
10727
10847
  * 設計ポイント:
10728
10848
  * - ループバインディングや多重ループ時のスコープ管理を安全に行う
10729
10849
  * - finallyで状態復元を保証し、例外発生時も安全
10730
- * - 非同期処理にも対応
10850
+ *
10851
+ * **スコープは同期である(重要)**:
10852
+ * push/pop は `callback()` の同期リターンで完結する。callback が Promise を返した
10853
+ * 場合、finally はその Promise が settle する *前* に走るため、await を跨いだ先では
10854
+ * ループコンテキストは既に外れている。したがって async なハンドラが await の後に
10855
+ * `$1` や wildcard パス(`items.*.id`)を触ると raiseError になる。
10856
+ *
10857
+ * これは silent な取り違えではなく loud な失敗であり、意図した挙動である
10858
+ * (特性化テスト: `__tests__/poc.asyncOnLoopContext.test.ts`)。await の後に行位置が
10859
+ * 必要な場合は、ハンドラ引数で受け取った listIndexes を
10860
+ * `$resolve(path, indexes, value?)` に渡すこと。listIndexes は素の数値配列なので
10861
+ * await を跨いでも安全に持ち回せる。
10862
+ *
10863
+ * 補足: かつて `setLoopContextAsync` という変種が存在したが、実体は
10864
+ * `await _setLoopContext(...)`(= finally が既に走った後の Promise を await するだけ)
10865
+ * であり、名前が示唆する「コンテキストを await 跨ぎで保持する」挙動は持っていなかった。
10866
+ * production の呼び出し元も無かったため削除した。同等の機能が必要になった場合は、
10867
+ * 「名前どおりに動く」実装を新規に起こすこと。
10731
10868
  */
10732
- function _setLoopContext(handler, loopContext, callback) {
10869
+ function setLoopContext(handler, loopContext, callback) {
10733
10870
  if (typeof handler.loopContext !== "undefined") {
10734
10871
  raiseError('already in loop context');
10735
10872
  }
@@ -10747,12 +10884,6 @@ function _setLoopContext(handler, loopContext, callback) {
10747
10884
  handler.clearLoopContext();
10748
10885
  }
10749
10886
  }
10750
- function setLoopContext(handler, loopContext, callback) {
10751
- return _setLoopContext(handler, loopContext, callback);
10752
- }
10753
- async function setLoopContextAsync(handler, loopContext, callback) {
10754
- return await _setLoopContext(handler, loopContext, callback);
10755
- }
10756
10887
 
10757
10888
  /**
10758
10889
  * get.ts
@@ -10870,12 +11001,6 @@ function get(target, prop, receiver, handler) {
10870
11001
  }
10871
11002
  let api;
10872
11003
  switch (prop) {
10873
- case setLoopContextAsyncSymbol: {
10874
- api = (loopContext, callback = async () => { }) => {
10875
- return setLoopContextAsync(handler, loopContext, callback);
10876
- };
10877
- break;
10878
- }
10879
11004
  case setLoopContextSymbol: {
10880
11005
  api = (loopContext, callback = () => { }) => {
10881
11006
  return setLoopContext(handler, loopContext, callback);