@wcstack/state 1.23.0 → 1.25.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.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.25.0";
6990
7106
  var pkg = {
6991
7107
  version: version};
6992
7108
 
@@ -9854,6 +9970,108 @@ function setSwapInfoByAddress(address, swapInfo) {
9854
9970
  }
9855
9971
  }
9856
9972
 
9973
+ /**
9974
+ * topologicalRank.ts — 依存グラフ(パス単位)のトポロジカル順位。
9975
+ *
9976
+ * 依存ウォークは list → list.* を展開するために途中でリスト実体を読む。この読み取りが
9977
+ * 正しい値を返すには「そのパスの入力(先行パス)がすべて dirty 化済み」である必要がある。
9978
+ * DFS ではダイヤモンド依存で片腕しか dirty 化していない段階で合流点を評価してしまうため、
9979
+ * パス単位の rank(= 最長経路長)を先に求め、rank の昇順で訪問する。
9980
+ *
9981
+ * rank の定義から、辺 (u → v) が存在すれば必ず rank(u) < rank(v) となる。したがって
9982
+ * rank r のバケットを処理する時点で rank < r のパスはすべて訪問(dirty 化)済みであり、
9983
+ * 同じバケット内のパス同士は互いに先行関係を持たない。
9984
+ *
9985
+ * 値を読まないグラフ走査なので、ウォーク 1 回あたりの追加コストは実測で誤差に
9986
+ * 収まる(メモ化しても差が出なかったため、キャッシュは持たない)。
9987
+ */
9988
+ function getTopologicalRanks(startPath, staticMap, dynamicMap, maxDepth) {
9989
+ // 1) startPath から到達可能なパス部分グラフと入次数を求める(値は一切読まない)
9990
+ const adjacency = new Map();
9991
+ const inDegree = new Map();
9992
+ const pending = [startPath];
9993
+ inDegree.set(startPath, 0);
9994
+ while (pending.length > 0) {
9995
+ const path = pending.pop();
9996
+ if (adjacency.has(path)) {
9997
+ continue;
9998
+ }
9999
+ const staticDeps = staticMap.get(path);
10000
+ const dynamicDeps = dynamicMap.get(path);
10001
+ let deps;
10002
+ if (staticDeps === undefined) {
10003
+ deps = dynamicDeps ?? [];
10004
+ }
10005
+ else if (dynamicDeps === undefined) {
10006
+ deps = staticDeps;
10007
+ }
10008
+ else {
10009
+ deps = staticDeps.concat(dynamicDeps);
10010
+ }
10011
+ adjacency.set(path, deps);
10012
+ for (let i = 0; i < deps.length; i++) {
10013
+ const dep = deps[i];
10014
+ inDegree.set(dep, (inDegree.get(dep) ?? 0) + 1);
10015
+ if (!adjacency.has(dep)) {
10016
+ pending.push(dep);
10017
+ }
10018
+ }
10019
+ }
10020
+ // 2) Kahn 法。rank は最長経路長(rank[v] = max(rank[u]) + 1)。
10021
+ // 入次数が 0 に落ちて queue に入ったパスだけが「確定」で、緩和の途中で
10022
+ // 暫定値が入っただけのパス(= 循環の一部)は確定扱いにしない。
10023
+ const ranks = new Map();
10024
+ const settled = new Set();
10025
+ const queue = [];
10026
+ for (const [path, degree] of inDegree) {
10027
+ if (degree === 0) {
10028
+ ranks.set(path, 0);
10029
+ settled.add(path);
10030
+ queue.push(path);
10031
+ }
10032
+ }
10033
+ for (let i = 0; i < queue.length; i++) {
10034
+ const path = queue[i];
10035
+ const nextRank = ranks.get(path) + 1;
10036
+ if (nextRank > maxDepth) {
10037
+ raiseError(`Maximum dependency depth of ${maxDepth} exceeded. Possible circular dependency detected at path: ${path}`);
10038
+ }
10039
+ const deps = adjacency.get(path);
10040
+ for (let j = 0; j < deps.length; j++) {
10041
+ const dep = deps[j];
10042
+ if (nextRank > (ranks.get(dep) ?? -1)) {
10043
+ ranks.set(dep, nextRank);
10044
+ }
10045
+ const remaining = inDegree.get(dep) - 1;
10046
+ inDegree.set(dep, remaining);
10047
+ if (remaining === 0) {
10048
+ settled.add(dep);
10049
+ queue.push(dep);
10050
+ }
10051
+ }
10052
+ }
10053
+ // 3) 循環に含まれるパスは rank が決まらない(入次数が 0 に落ちない)。
10054
+ // そもそも正しい評価順が存在しないので、順序保証を諦めて確定済みの
10055
+ // 最大 rank の次にまとめる。打ち切りは従来どおり visited が担う。
10056
+ // 暫定値が残っていると確定パスとの前後関係を誤って表すため、必ず上書きする。
10057
+ if (settled.size !== adjacency.size) {
10058
+ let maxRank = -1;
10059
+ for (const path of settled) {
10060
+ const rank = ranks.get(path);
10061
+ if (rank > maxRank) {
10062
+ maxRank = rank;
10063
+ }
10064
+ }
10065
+ const cycleRank = maxRank + 1;
10066
+ for (const path of adjacency.keys()) {
10067
+ if (!settled.has(path)) {
10068
+ ranks.set(path, cycleRank);
10069
+ }
10070
+ }
10071
+ }
10072
+ return ranks;
10073
+ }
10074
+
9857
10075
  const MAX_DEPENDENCY_DEPTH = 1000;
9858
10076
  function getIndexes(listDiff, searchType) {
9859
10077
  switch (searchType) {
@@ -9960,152 +10178,178 @@ function getMovedRowExpansionPaths(context, wildcardPath, depPathInfo) {
9960
10178
  return result ?? EMPTY_PATH_INFOS;
9961
10179
  }
9962
10180
  function _walkDependency(context, startAddress, callback) {
9963
- const stack = [{ address: startAddress, depth: 0 }];
9964
- while (stack.length > 0) {
9965
- const { address, depth } = stack.pop();
9966
- if (depth > MAX_DEPENDENCY_DEPTH) {
9967
- raiseError(`Maximum dependency depth of ${MAX_DEPENDENCY_DEPTH} exceeded. Possible circular dependency detected at path: ${address.pathInfo.path}`);
9968
- }
9969
- if (context.visited.has(address)) {
10181
+ // rank ごとのバケットで訪問する。辺 (u v) では必ず rank(u) < rank(v) なので、
10182
+ // バケット r を処理する時点で rank < r のパスは全て dirty 化済みになる
10183
+ // ここでリスト実体を読んでも入力が揃っている(topologicalRank.ts 参照)。
10184
+ const buckets = [];
10185
+ const ranks = context.ranks;
10186
+ const enqueue = (address, minRank) => {
10187
+ let rank = ranks.get(address.pathInfo.path) ?? minRank;
10188
+ if (rank < minRank) {
10189
+ // 循環など rank が先行関係を表せないケース。現在のバケットに載せて
10190
+ // 同一ループ内で処理する(打ち切りは visited が担う)。
10191
+ rank = minRank;
10192
+ }
10193
+ (buckets[rank] ??= []).push(address);
10194
+ };
10195
+ enqueue(startAddress, 0);
10196
+ // 依存アドレスを収集するための一時バッファ(アドレスごとに使い回す)
10197
+ const nextEntries = [];
10198
+ for (let rank = 0; rank < buckets.length; rank++) {
10199
+ const bucket = buckets[rank];
10200
+ if (bucket === undefined) {
9970
10201
  continue;
9971
10202
  }
9972
- context.visited.add(address);
9973
- callback(address);
9974
- const sourcePath = address.pathInfo.path;
9975
- const nextDepth = depth + 1;
9976
- // 依存アドレスを逆順でpushするための一時バッファ
9977
- const nextEntries = [];
9978
- /**
9979
- * パスから依存関係をたどる
9980
- * users.*.name <= users.* <= users
9981
- * ただし、users がリストであれば users.* の依存関係は展開する
9982
- */
9983
- const staticDeps = context.staticMap.get(sourcePath);
9984
- if (staticDeps) {
9985
- for (const dep of staticDeps) {
9986
- const depPathInfo = getPathInfo(dep);
9987
- if (context.listPathSet.has(sourcePath) && depPathInfo.lastSegment === WILDCARD) {
9988
- //expand indexes
9989
- const newValue = context.stateProxy[getByAddressSymbol](address);
9990
- const absPathInfo = getAbsolutePathInfo(context.stateElement, address.pathInfo);
9991
- const absAddress = createAbsoluteStateAddress(absPathInfo, address.listIndex);
9992
- const lastValue = getLastListValueByAbsoluteStateAddress(absAddress);
9993
- const listDiff = createListDiff(address.listIndex, lastValue, newValue);
9994
- const selection = selectExpansionIndexes(context, sourcePath, lastValue, newValue, listDiff);
9995
- for (const listIndex of selection.fullRows) {
9996
- const depAddress = createStateAddress(depPathInfo, listIndex);
9997
- context.result.add(depAddress);
9998
- nextEntries.push({ address: depAddress, depth: nextDepth });
10203
+ // 同一バケットへの push(循環時)で伸びるため length は都度読む
10204
+ for (let cursor = 0; cursor < bucket.length; cursor++) {
10205
+ const address = bucket[cursor];
10206
+ if (context.visited.has(address)) {
10207
+ continue;
10208
+ }
10209
+ context.visited.add(address);
10210
+ callback(address);
10211
+ nextEntries.length = 0;
10212
+ _collectDependencies(context, address, nextEntries);
10213
+ for (let i = 0; i < nextEntries.length; i++) {
10214
+ enqueue(nextEntries[i], rank + 1);
10215
+ }
10216
+ }
10217
+ }
10218
+ }
10219
+ /**
10220
+ * address の依存アドレスを nextEntries に集め、context.result にも登録する。
10221
+ * リスト展開(list list.*)と動的依存のワイルドカード展開はここで値を読むが、
10222
+ * 呼び出し元がトポロジカル順を保証しているため入力は揃っている。
10223
+ */
10224
+ function _collectDependencies(context, address, nextEntries) {
10225
+ const sourcePath = address.pathInfo.path;
10226
+ /**
10227
+ * パスから依存関係をたどる
10228
+ * users.*.name <= users.* <= users
10229
+ * ただし、users がリストであれば users.* の依存関係は展開する
10230
+ */
10231
+ const staticDeps = context.staticMap.get(sourcePath);
10232
+ if (staticDeps) {
10233
+ for (const dep of staticDeps) {
10234
+ const depPathInfo = getPathInfo(dep);
10235
+ if (context.listPathSet.has(sourcePath) && depPathInfo.lastSegment === WILDCARD) {
10236
+ //expand indexes
10237
+ const newValue = context.stateProxy[getByAddressSymbol](address);
10238
+ const absPathInfo = getAbsolutePathInfo(context.stateElement, address.pathInfo);
10239
+ const absAddress = createAbsoluteStateAddress(absPathInfo, address.listIndex);
10240
+ const lastValue = getLastListValueByAbsoluteStateAddress(absAddress);
10241
+ const listDiff = createListDiff(address.listIndex, lastValue, newValue);
10242
+ const selection = selectExpansionIndexes(context, sourcePath, lastValue, newValue, listDiff);
10243
+ for (const listIndex of selection.fullRows) {
10244
+ const depAddress = createStateAddress(depPathInfo, listIndex);
10245
+ context.result.add(depAddress);
10246
+ nextEntries.push(depAddress);
10247
+ }
10248
+ if (selection.movedRows !== null) {
10249
+ const movedPathInfos = getMovedRowExpansionPaths(context, dep, depPathInfo);
10250
+ if (movedPathInfos === null) {
10251
+ // ネスト配下に index 依存 getter: 安全側で行全体を展開(従来挙動)
10252
+ for (const listIndex of selection.movedRows) {
10253
+ const depAddress = createStateAddress(depPathInfo, listIndex);
10254
+ context.result.add(depAddress);
10255
+ nextEntries.push(depAddress);
10256
+ }
9999
10257
  }
10000
- if (selection.movedRows !== null) {
10001
- const movedPathInfos = getMovedRowExpansionPaths(context, dep, depPathInfo);
10002
- if (movedPathInfos === null) {
10003
- // ネスト配下に index 依存 getter: 安全側で行全体を展開(従来挙動)
10004
- for (const listIndex of selection.movedRows) {
10005
- const depAddress = createStateAddress(depPathInfo, listIndex);
10258
+ else if (movedPathInfos.length > 0) {
10259
+ // 位置のみ変わった行は index 依存 getter のパスだけを展開する
10260
+ for (const listIndex of selection.movedRows) {
10261
+ for (const pathInfo of movedPathInfos) {
10262
+ const depAddress = createStateAddress(pathInfo, listIndex);
10006
10263
  context.result.add(depAddress);
10007
- nextEntries.push({ address: depAddress, depth: nextDepth });
10008
- }
10009
- }
10010
- else if (movedPathInfos.length > 0) {
10011
- // 位置のみ変わった行は index 依存 getter のパスだけを展開する
10012
- for (const listIndex of selection.movedRows) {
10013
- for (const pathInfo of movedPathInfos) {
10014
- const depAddress = createStateAddress(pathInfo, listIndex);
10015
- context.result.add(depAddress);
10016
- nextEntries.push({ address: depAddress, depth: nextDepth });
10017
- }
10264
+ nextEntries.push(depAddress);
10018
10265
  }
10019
10266
  }
10020
- // movedPathInfos が空: index を読む getter が subtree に無い =
10021
- // 位置のみ変わった行の値は不変。展開・dirty 化とも不要。
10022
10267
  }
10023
- }
10024
- else {
10025
- const depAddress = createStateAddress(depPathInfo, address.listIndex);
10026
- context.result.add(depAddress);
10027
- nextEntries.push({ address: depAddress, depth: nextDepth });
10268
+ // movedPathInfos が空: index を読む getter が subtree に無い =
10269
+ // 位置のみ変わった行の値は不変。展開・dirty 化とも不要。
10028
10270
  }
10029
10271
  }
10272
+ else {
10273
+ const depAddress = createStateAddress(depPathInfo, address.listIndex);
10274
+ context.result.add(depAddress);
10275
+ nextEntries.push(depAddress);
10276
+ }
10030
10277
  }
10031
- /**
10032
- * 動的依存関係をたどる
10033
- * 動的依存関係は、getterの実行時に決定される
10034
- *
10035
- * source, target
10036
- *
10037
- * products.*.price => products.*.tax
10038
- * get "products.*.tax"() { return this["products.*.price"] * 0.1; }
10039
- *
10040
- * products.*.price => products.summary
10041
- * get "products.summary"() { return this.$getAll("products.*.price", []).reduce(sum); }
10042
- *
10043
- * categories.*.name => categories.*.products.*.categoryName
10044
- * get "categories.*.products.*.categoryName"() { return this["categories.*.name"]; }
10045
- */
10046
- const dynamicDeps = context.dynamicMap.get(sourcePath);
10047
- if (dynamicDeps) {
10048
- for (const dep of dynamicDeps) {
10049
- const depPathInfo = getPathInfo(dep);
10050
- const listIndexes = [];
10051
- if (depPathInfo.wildcardCount > 0) {
10052
- // ワイルドカードを含む依存関係の処理
10053
- // 同じ親を持つかをパスの集合積で判定する
10054
- // polyfills.tsにてSetのintersectionメソッドを定義している
10055
- const wildcardLen = calcWildcardLen(address.pathInfo, depPathInfo);
10056
- const expandable = (depPathInfo.wildcardCount - wildcardLen) >= 1;
10057
- if (expandable) {
10058
- let listIndex;
10059
- if (wildcardLen > 0) {
10060
- // categories.*.name => categories.*.products.*.categoryName
10061
- // ワイルドカードを含む同じ親(products.*)を持つのが、
10062
- // さらに下位にワイルドカードがあるので展開する
10063
- if (address.listIndex === null) {
10064
- raiseError(`Cannot expand dynamic dependency with wildcard for non-list address: ${address.pathInfo.path}`);
10065
- }
10066
- listIndex = address.listIndex.at(wildcardLen - 1);
10067
- }
10068
- else {
10069
- // selectedIndex => items.*.selected
10070
- // 同じ親を持たない場合はnullから開始
10071
- listIndex = null;
10072
- }
10073
- const expandContext = {
10074
- stateElement: context.stateElement,
10075
- targetListIndexes: [],
10076
- wildcardPaths: depPathInfo.wildcardPaths,
10077
- wildcardParentPaths: depPathInfo.wildcardParentPaths,
10078
- stateProxy: context.stateProxy,
10079
- searchType: context.searchType,
10080
- };
10081
- _walkExpandWildcard(expandContext, wildcardLen, listIndex);
10082
- listIndexes.push(...expandContext.targetListIndexes);
10083
- }
10084
- else {
10085
- // products.*.price => products.*.tax
10086
- // ワイルドカードを含む同じ親(products.*)を持つので、リストインデックスは引き継ぐ
10278
+ }
10279
+ /**
10280
+ * 動的依存関係をたどる
10281
+ * 動的依存関係は、getterの実行時に決定される
10282
+ *
10283
+ * source, target
10284
+ *
10285
+ * products.*.price => products.*.tax
10286
+ * get "products.*.tax"() { return this["products.*.price"] * 0.1; }
10287
+ *
10288
+ * products.*.price => products.summary
10289
+ * get "products.summary"() { return this.$getAll("products.*.price", []).reduce(sum); }
10290
+ *
10291
+ * categories.*.name => categories.*.products.*.categoryName
10292
+ * get "categories.*.products.*.categoryName"() { return this["categories.*.name"]; }
10293
+ */
10294
+ const dynamicDeps = context.dynamicMap.get(sourcePath);
10295
+ if (dynamicDeps) {
10296
+ for (const dep of dynamicDeps) {
10297
+ const depPathInfo = getPathInfo(dep);
10298
+ const listIndexes = [];
10299
+ if (depPathInfo.wildcardCount > 0) {
10300
+ // ワイルドカードを含む依存関係の処理
10301
+ // 同じ親を持つかをパスの集合積で判定する
10302
+ // polyfills.tsにてSetのintersectionメソッドを定義している
10303
+ const wildcardLen = calcWildcardLen(address.pathInfo, depPathInfo);
10304
+ const expandable = (depPathInfo.wildcardCount - wildcardLen) >= 1;
10305
+ if (expandable) {
10306
+ let listIndex;
10307
+ if (wildcardLen > 0) {
10308
+ // categories.*.name => categories.*.products.*.categoryName
10309
+ // ワイルドカードを含む同じ親(products.*)を持つのが、
10310
+ // さらに下位にワイルドカードがあるので展開する
10087
10311
  if (address.listIndex === null) {
10088
10312
  raiseError(`Cannot expand dynamic dependency with wildcard for non-list address: ${address.pathInfo.path}`);
10089
10313
  }
10090
- const listIndex = address.listIndex.at(wildcardLen - 1);
10091
- listIndexes.push(listIndex);
10314
+ listIndex = address.listIndex.at(wildcardLen - 1);
10092
10315
  }
10316
+ else {
10317
+ // selectedIndex => items.*.selected
10318
+ // 同じ親を持たない場合はnullから開始
10319
+ listIndex = null;
10320
+ }
10321
+ const expandContext = {
10322
+ stateName: context.stateName,
10323
+ stateElement: context.stateElement,
10324
+ targetListIndexes: [],
10325
+ wildcardPaths: depPathInfo.wildcardPaths,
10326
+ wildcardParentPaths: depPathInfo.wildcardParentPaths,
10327
+ stateProxy: context.stateProxy,
10328
+ searchType: context.searchType,
10329
+ };
10330
+ _walkExpandWildcard(expandContext, wildcardLen, listIndex);
10331
+ listIndexes.push(...expandContext.targetListIndexes);
10093
10332
  }
10094
10333
  else {
10095
- // products.*.tax => currentTaxRate
10096
- // 同じ親を持たないので、リストインデックスはnull
10097
- listIndexes.push(null);
10098
- }
10099
- for (const listIndex of listIndexes) {
10100
- const depAddress = createStateAddress(depPathInfo, listIndex);
10101
- context.result.add(depAddress);
10102
- nextEntries.push({ address: depAddress, depth: nextDepth });
10334
+ // products.*.price => products.*.tax
10335
+ // ワイルドカードを含む同じ親(products.*)を持つので、リストインデックスは引き継ぐ
10336
+ if (address.listIndex === null) {
10337
+ raiseError(`Cannot expand dynamic dependency with wildcard for non-list address: ${address.pathInfo.path}`);
10338
+ }
10339
+ const listIndex = address.listIndex.at(wildcardLen - 1);
10340
+ listIndexes.push(listIndex);
10103
10341
  }
10104
10342
  }
10105
- }
10106
- // 逆順でpushして、元の再帰と同じ探索順序を保つ
10107
- for (let i = nextEntries.length - 1; i >= 0; i--) {
10108
- stack.push(nextEntries[i]);
10343
+ else {
10344
+ // products.*.tax => currentTaxRate
10345
+ // 同じ親を持たないので、リストインデックスはnull
10346
+ listIndexes.push(null);
10347
+ }
10348
+ for (const listIndex of listIndexes) {
10349
+ const depAddress = createStateAddress(depPathInfo, listIndex);
10350
+ context.result.add(depAddress);
10351
+ nextEntries.push(depAddress);
10352
+ }
10109
10353
  }
10110
10354
  }
10111
10355
  }
@@ -10119,7 +10363,12 @@ function walkDependency(stateName, stateElement, startAddress, staticDependency,
10119
10363
  callback(startAddress);
10120
10364
  return [];
10121
10365
  }
10366
+ // パス単位のトポロジカル順位。値を一切読まずに求まり、依存グラフは追記のみで
10367
+ // 成長するため epoch でメモ化される(topologicalRank.ts)。
10368
+ const ranks = getTopologicalRanks(startPath, staticDependency, dynamicDependency, MAX_DEPENDENCY_DEPTH);
10122
10369
  const context = {
10370
+ ranks: ranks,
10371
+ stateName: stateName,
10123
10372
  stateElement: stateElement,
10124
10373
  staticMap: staticDependency,
10125
10374
  dynamicMap: dynamicDependency,
@@ -10258,6 +10507,10 @@ function _setByAddressWithSwap(target, address, absAddress, value, receiver, han
10258
10507
  function setByAddress(target, address, value, receiver, handler) {
10259
10508
  const stateElement = handler.stateElement;
10260
10509
  const path = address.pathInfo.path;
10510
+ // occurrence(wc-bindable の `semantics: "event"`)由来の書き込みは、同値でも
10511
+ // 「もう一度起きた」ことを落としてはならないため same-value guard を 1 回だけ飛ばす。
10512
+ // トークンはここで消費されるので、この write の内側で走る他の書き込みには波及しない。
10513
+ const skipSameValueGuard = consumeOccurrenceWrite();
10261
10514
  // --- fast path: 宣言済み getter/setter でも swap 対象でもない、親を持つ葉パス ---
10262
10515
  // 従来は same-value guard の値読み・hasByAddress・実書き込みがそれぞれ親チェーンを
10263
10516
  // 解決していた(キャッシュヒットでも getByAddress 呼び出しの固定費 ×3)。
@@ -10274,7 +10527,7 @@ function setByAddress(target, address, value, receiver, handler) {
10274
10527
  : lastSegment;
10275
10528
  let devOldValue;
10276
10529
  let devHasOldValue = false;
10277
- if (config.sameValueGuard && (value === null || typeof value !== "object")) {
10530
+ if (!skipSameValueGuard && config.sameValueGuard && (value === null || typeof value !== "object")) {
10278
10531
  // hasByAddress と同じ「初期化済みスロットか」判定(undefined 格納と未初期化を区別)
10279
10532
  const has = key !== undefined && key in parentValue;
10280
10533
  const oldValue = key !== undefined ? parentValue[key] : undefined;
@@ -10334,7 +10587,7 @@ function setByAddress(target, address, value, receiver, handler) {
10334
10587
  // (参照型のために追加の get はしない — protocol §4.2)
10335
10588
  let devOldValue;
10336
10589
  let devHasOldValue = false;
10337
- if (config.sameValueGuard && (value === null || typeof value !== "object")) {
10590
+ if (!skipSameValueGuard && config.sameValueGuard && (value === null || typeof value !== "object")) {
10338
10591
  const oldValue = getByAddress(target, address, receiver, handler);
10339
10592
  if (hasByAddress(target, address, receiver, handler) && Object.is(oldValue, value)) {
10340
10593
  return true;
@@ -10716,7 +10969,7 @@ function updatedCallback(target, refs, receiver, handler) {
10716
10969
  * setLoopContext.ts
10717
10970
  *
10718
10971
  * StateClassの内部APIとして、ループコンテキスト(ILoopContext)を一時的に設定し、
10719
- * 指定した同期/非同期コールバックをそのスコープ内で実行するための関数です。
10972
+ * 指定したコールバックをそのスコープ内で実行するための関数です。
10720
10973
  *
10721
10974
  * 主な役割:
10722
10975
  * - handler.loopContextにループコンテキストを一時的に設定
@@ -10727,9 +10980,26 @@ function updatedCallback(target, refs, receiver, handler) {
10727
10980
  * 設計ポイント:
10728
10981
  * - ループバインディングや多重ループ時のスコープ管理を安全に行う
10729
10982
  * - finallyで状態復元を保証し、例外発生時も安全
10730
- * - 非同期処理にも対応
10983
+ *
10984
+ * **スコープは同期である(重要)**:
10985
+ * push/pop は `callback()` の同期リターンで完結する。callback が Promise を返した
10986
+ * 場合、finally はその Promise が settle する *前* に走るため、await を跨いだ先では
10987
+ * ループコンテキストは既に外れている。したがって async なハンドラが await の後に
10988
+ * `$1` や wildcard パス(`items.*.id`)を触ると raiseError になる。
10989
+ *
10990
+ * これは silent な取り違えではなく loud な失敗であり、意図した挙動である
10991
+ * (特性化テスト: `__tests__/poc.asyncOnLoopContext.test.ts`)。await の後に行位置が
10992
+ * 必要な場合は、ハンドラ引数で受け取った listIndexes を
10993
+ * `$resolve(path, indexes, value?)` に渡すこと。listIndexes は素の数値配列なので
10994
+ * await を跨いでも安全に持ち回せる。
10995
+ *
10996
+ * 補足: かつて `setLoopContextAsync` という変種が存在したが、実体は
10997
+ * `await _setLoopContext(...)`(= finally が既に走った後の Promise を await するだけ)
10998
+ * であり、名前が示唆する「コンテキストを await 跨ぎで保持する」挙動は持っていなかった。
10999
+ * production の呼び出し元も無かったため削除した。同等の機能が必要になった場合は、
11000
+ * 「名前どおりに動く」実装を新規に起こすこと。
10731
11001
  */
10732
- function _setLoopContext(handler, loopContext, callback) {
11002
+ function setLoopContext(handler, loopContext, callback) {
10733
11003
  if (typeof handler.loopContext !== "undefined") {
10734
11004
  raiseError('already in loop context');
10735
11005
  }
@@ -10747,12 +11017,6 @@ function _setLoopContext(handler, loopContext, callback) {
10747
11017
  handler.clearLoopContext();
10748
11018
  }
10749
11019
  }
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
11020
 
10757
11021
  /**
10758
11022
  * get.ts
@@ -10870,12 +11134,6 @@ function get(target, prop, receiver, handler) {
10870
11134
  }
10871
11135
  let api;
10872
11136
  switch (prop) {
10873
- case setLoopContextAsyncSymbol: {
10874
- api = (loopContext, callback = async () => { }) => {
10875
- return setLoopContextAsync(handler, loopContext, callback);
10876
- };
10877
- break;
10878
- }
10879
11137
  case setLoopContextSymbol: {
10880
11138
  api = (loopContext, callback = () => { }) => {
10881
11139
  return setLoopContext(handler, loopContext, callback);