@wcstack/state 1.18.0 → 1.19.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
@@ -63,7 +63,7 @@ function setConfig(partialConfig) {
63
63
  }
64
64
  }
65
65
 
66
- var version$1 = "1.18.0";
66
+ var version$1 = "1.19.0";
67
67
  var pkg = {
68
68
  version: version$1};
69
69
 
@@ -1846,13 +1846,62 @@ const connectedCallbackSymbol = Symbol("$$connectedCallback");
1846
1846
  const disconnectedCallbackSymbol = Symbol("$$disconnectedCallback");
1847
1847
  const updatedCallbackSymbol = Symbol("$$updatedCallback");
1848
1848
 
1849
+ function createHandlerBindingRegistry() {
1850
+ const attachedByKey = new Map();
1851
+ const countByKey = new Map();
1852
+ return {
1853
+ add(key, binding) {
1854
+ let attached = attachedByKey.get(key);
1855
+ if (typeof attached === "undefined") {
1856
+ attached = new WeakSet();
1857
+ attachedByKey.set(key, attached);
1858
+ }
1859
+ if (attached.has(binding)) {
1860
+ return false;
1861
+ }
1862
+ attached.add(binding);
1863
+ countByKey.set(key, (countByKey.get(key) ?? 0) + 1);
1864
+ return true;
1865
+ },
1866
+ remove(key, binding) {
1867
+ const attached = attachedByKey.get(key);
1868
+ if (typeof attached === "undefined" || !attached.has(binding)) {
1869
+ return false;
1870
+ }
1871
+ attached.delete(binding);
1872
+ const next = (countByKey.get(key) ?? 1) - 1;
1873
+ if (next <= 0) {
1874
+ attachedByKey.delete(key);
1875
+ countByKey.delete(key);
1876
+ return true;
1877
+ }
1878
+ countByKey.set(key, next);
1879
+ return false;
1880
+ },
1881
+ has(key, binding) {
1882
+ return attachedByKey.get(key)?.has(binding) ?? false;
1883
+ },
1884
+ countOf(key) {
1885
+ return countByKey.get(key) ?? 0;
1886
+ },
1887
+ get keyCount() {
1888
+ return countByKey.size;
1889
+ },
1890
+ clear() {
1891
+ attachedByKey.clear();
1892
+ countByKey.clear();
1893
+ },
1894
+ };
1895
+ }
1896
+
1849
1897
  // onclick: $command.<name> のように、DOM イベントから command token を直接 emit する形式かを判定する。
1850
1898
  // 右辺が $command 名前空間配下のパス($command.<token>)のときに true。
1851
1899
  function isCommandTokenPath(statePathName) {
1852
1900
  return statePathName.startsWith(STATE_COMMAND_NAMESPACE_NAME + ".");
1853
1901
  }
1854
1902
  const handlerByHandlerKey$3 = new Map();
1855
- const bindingSetByHandlerKey$3 = new Map();
1903
+ // binding を強参照しない台帳(handlerBindingRegistry.ts のリーク解説を参照)
1904
+ const bindingRegistry$3 = createHandlerBindingRegistry();
1856
1905
  function getHandlerKey$3(binding) {
1857
1906
  const modifierKey = binding.propModifiers.filter(m => m === 'prevent' || m === 'stop').sort().join(',');
1858
1907
  return `${binding.stateName}::${binding.statePathName}::${modifierKey}`;
@@ -1901,14 +1950,7 @@ function attachEventHandler(binding) {
1901
1950
  }
1902
1951
  const eventName = binding.propName.slice(2);
1903
1952
  binding.node.addEventListener(eventName, stateEventHandler);
1904
- let bindingSet = bindingSetByHandlerKey$3.get(key);
1905
- if (typeof bindingSet === "undefined") {
1906
- bindingSet = new Set([binding]);
1907
- bindingSetByHandlerKey$3.set(key, bindingSet);
1908
- }
1909
- else {
1910
- bindingSet.add(binding);
1911
- }
1953
+ bindingRegistry$3.add(key, binding);
1912
1954
  return true;
1913
1955
  }
1914
1956
 
@@ -2078,7 +2120,8 @@ function isPossibleTwoWay(node, propName) {
2078
2120
  }
2079
2121
 
2080
2122
  const handlerByHandlerKey$2 = new Map();
2081
- const bindingSetByHandlerKey$2 = new Map();
2123
+ // binding を強参照しない台帳(handlerBindingRegistry.ts のリーク解説を参照)
2124
+ const bindingRegistry$2 = createHandlerBindingRegistry();
2082
2125
  const DEFAULT_GETTER = (e) => e.detail;
2083
2126
  function getHandlerKey$2(binding, eventName, hasGetter) {
2084
2127
  const filterKey = binding.inFilters.map(f => f.filterName + '(' + f.args.join(',') + ')').join('|');
@@ -2181,14 +2224,7 @@ function attachTwowayEventHandler(binding) {
2181
2224
  handlerByHandlerKey$2.set(key, twowayEventHandler);
2182
2225
  }
2183
2226
  binding.node.addEventListener(eventName, twowayEventHandler);
2184
- let bindingSet = bindingSetByHandlerKey$2.get(key);
2185
- if (typeof bindingSet === "undefined") {
2186
- bindingSet = new Set([binding]);
2187
- bindingSetByHandlerKey$2.set(key, bindingSet);
2188
- }
2189
- else {
2190
- bindingSet.add(binding);
2191
- }
2227
+ bindingRegistry$2.add(key, binding);
2192
2228
  }
2193
2229
  }
2194
2230
 
@@ -2893,6 +2929,103 @@ function calcDiffIndexes(oldList, newList, oldIndexes, newIndexes, indexByValue)
2893
2929
  };
2894
2930
  }
2895
2931
 
2932
+ /**
2933
+ * Indices into `seq` whose values form a longest strictly-increasing
2934
+ * subsequence, returned in ascending order. Classic patience-sorting
2935
+ * LIS in O(n log n). `seq` values are assumed distinct (old list
2936
+ * positions are unique).
2937
+ */
2938
+ function longestIncreasingSubsequence(seq) {
2939
+ const n = seq.length;
2940
+ // tails[k] = index into seq of the smallest tail of an increasing
2941
+ // subsequence of length k+1; prev[i] = predecessor index to rebuild the chain.
2942
+ const tails = [];
2943
+ const prev = new Array(n).fill(-1);
2944
+ for (let i = 0; i < n; i++) {
2945
+ const value = seq[i];
2946
+ let lo = 0;
2947
+ let hi = tails.length;
2948
+ while (lo < hi) {
2949
+ const mid = (lo + hi) >> 1;
2950
+ if (seq[tails[mid]] < value) {
2951
+ lo = mid + 1;
2952
+ }
2953
+ else {
2954
+ hi = mid;
2955
+ }
2956
+ }
2957
+ if (lo > 0) {
2958
+ prev[i] = tails[lo - 1];
2959
+ }
2960
+ tails[lo] = i;
2961
+ }
2962
+ const result = [];
2963
+ let k = tails.length > 0 ? tails[tails.length - 1] : -1;
2964
+ while (k >= 0) {
2965
+ result.push(k);
2966
+ k = prev[k];
2967
+ }
2968
+ result.reverse();
2969
+ return result;
2970
+ }
2971
+ /**
2972
+ * Determines which reused list indexes can stay where they are when the DOM
2973
+ * is brought into the new list order.
2974
+ *
2975
+ * Returns null when the reused indexes already appear in their old relative
2976
+ * order (no inversions) — the caller's existing position guard then performs
2977
+ * no moves, so nothing extra is needed. When inversions exist, returns the
2978
+ * set of indexes forming a longest increasing subsequence of old positions:
2979
+ * leaving exactly those in place and moving every other content yields the
2980
+ * correct final order with the fewest content moves (the naive forward walk
2981
+ * otherwise cascades: a single swap of rows 2/999 in 1000 rows moves ~997
2982
+ * contents instead of 2).
2983
+ *
2984
+ * Note: IListIndex.index is already mutated to the NEW position by
2985
+ * createListDiff, so old positions must come from the oldIndexes array order.
2986
+ */
2987
+ function computeStableIndexSet(diff) {
2988
+ // No reused index changed position, or nothing was reused: relative order
2989
+ // is already correct and the walk performs no moves.
2990
+ if (diff.changeIndexSet.size === 0 || diff.addIndexSet.size === diff.newIndexes.length) {
2991
+ return null;
2992
+ }
2993
+ const oldPosByIndex = new Map();
2994
+ for (let i = 0; i < diff.oldIndexes.length; i++) {
2995
+ oldPosByIndex.set(diff.oldIndexes[i], i);
2996
+ }
2997
+ const reused = [];
2998
+ const seq = [];
2999
+ let prevPos = -1;
3000
+ let sorted = true;
3001
+ for (const index of diff.newIndexes) {
3002
+ if (diff.addIndexSet.has(index)) {
3003
+ continue;
3004
+ }
3005
+ const pos = oldPosByIndex.get(index);
3006
+ if (pos === undefined) {
3007
+ // Invariant break (a reused index missing from oldIndexes): fall back
3008
+ // to the settle walk rather than compute a stable set from bad data.
3009
+ return null;
3010
+ }
3011
+ if (pos < prevPos) {
3012
+ sorted = false;
3013
+ }
3014
+ prevPos = pos;
3015
+ reused.push(index);
3016
+ seq.push(pos);
3017
+ }
3018
+ if (sorted) {
3019
+ return null;
3020
+ }
3021
+ const lis = longestIncreasingSubsequence(seq);
3022
+ const stable = new Set();
3023
+ for (const seqIndex of lis) {
3024
+ stable.add(reused[seqIndex]);
3025
+ }
3026
+ return stable;
3027
+ }
3028
+
2896
3029
  const bindingSetByAbsoluteStateAddress = new WeakMap();
2897
3030
  function getBindingSetByAbsoluteStateAddress(absoluteStateAddress) {
2898
3031
  let bindingSet = null;
@@ -2903,38 +3036,24 @@ function getBindingSetByAbsoluteStateAddress(absoluteStateAddress) {
2903
3036
  }
2904
3037
  return bindingSet;
2905
3038
  }
3039
+ /**
3040
+ * 参照専用の取得。get-or-create と違い、未登録アドレスに空 Set を
3041
+ * 生成・キャッシュしない(リスト置換の drain は大量のバインディング無し
3042
+ * アドレスを照会するため、生成すると空 Set が溜まり続ける)。
3043
+ */
3044
+ function peekBindingSetByAbsoluteStateAddress(absoluteStateAddress) {
3045
+ return bindingSetByAbsoluteStateAddress.get(absoluteStateAddress);
3046
+ }
2906
3047
  function addBindingByAbsoluteStateAddress(absoluteStateAddress, binding) {
2907
3048
  const bindingSet = getBindingSetByAbsoluteStateAddress(absoluteStateAddress);
2908
3049
  bindingSet.add(binding);
2909
3050
  }
2910
3051
  function removeBindingByAbsoluteStateAddress(absoluteStateAddress, binding) {
2911
- const bindingSet = getBindingSetByAbsoluteStateAddress(absoluteStateAddress);
2912
- bindingSet.delete(binding);
2913
- }
2914
-
2915
- const stateAddressByBindingInfo = new WeakMap();
2916
- function getStateAddressByBindingInfo(bindingInfo) {
2917
- let stateAddress = null;
2918
- stateAddress = stateAddressByBindingInfo.get(bindingInfo) || null;
2919
- if (stateAddress !== null) {
2920
- return stateAddress;
3052
+ // get-or-create を通すと未登録アドレスに空 Set を生成してしまうため素の get で参照する
3053
+ const bindingSet = bindingSetByAbsoluteStateAddress.get(absoluteStateAddress);
3054
+ if (bindingSet !== undefined) {
3055
+ bindingSet.delete(binding);
2921
3056
  }
2922
- if (bindingInfo.statePathInfo.wildcardCount > 0) {
2923
- const listIndex = getListIndexByBindingInfo(bindingInfo);
2924
- if (listIndex === null) {
2925
- raiseError(`Cannot resolve state address for binding with wildcard statePathName "${bindingInfo.statePathName}" because list index is null.`);
2926
- }
2927
- stateAddress = createStateAddress(bindingInfo.statePathInfo, listIndex);
2928
- }
2929
- else {
2930
- stateAddress = createStateAddress(bindingInfo.statePathInfo, null);
2931
- }
2932
- stateAddressByBindingInfo.set(bindingInfo, stateAddress);
2933
- return stateAddress;
2934
- }
2935
- // call for change loopContext
2936
- function clearStateAddressByBindingInfo(bindingInfo) {
2937
- stateAddressByBindingInfo.delete(bindingInfo);
2938
3057
  }
2939
3058
 
2940
3059
  const bindingsByContent = new WeakMap();
@@ -2983,8 +3102,10 @@ function deactivateContent(content) {
2983
3102
  for (const binding of bindings) {
2984
3103
  const absoluteStateAddress = getAbsoluteStateAddressByBinding(binding);
2985
3104
  removeBindingByAbsoluteStateAddress(absoluteStateAddress, binding);
2986
- clearAbsoluteStateAddressByBinding(binding);
2987
- clearStateAddressByBindingInfo(binding);
3105
+ // アドレスキャッシュ(absoluteStateAddressByBinding / stateAddressByBindingInfo)
3106
+ // のクリアはここでは行わない。deactivateContent の呼び出し元(for/if)は必ず
3107
+ // 直後に content.unmount() を呼び、unmount が同じ2台帳をネスト content も含めて
3108
+ // クリアする(createContent.ts)。ここで消すと全 binding で二重 delete になる。
2988
3109
  }
2989
3110
  unbindLoopContextToContent(content);
2990
3111
  }
@@ -3011,6 +3132,40 @@ function getContentSetByNode(node) {
3011
3132
  }
3012
3133
  return EMPTY_SET;
3013
3134
  }
3135
+ function deleteContentByNode(node, content) {
3136
+ const contents = contentSetByNode.get(node);
3137
+ if (contents) {
3138
+ contents.delete(content);
3139
+ if (contents.size === 0) {
3140
+ contentSetByNode.delete(node);
3141
+ }
3142
+ }
3143
+ }
3144
+
3145
+ const stateAddressByBindingInfo = new WeakMap();
3146
+ function getStateAddressByBindingInfo(bindingInfo) {
3147
+ let stateAddress = null;
3148
+ stateAddress = stateAddressByBindingInfo.get(bindingInfo) || null;
3149
+ if (stateAddress !== null) {
3150
+ return stateAddress;
3151
+ }
3152
+ if (bindingInfo.statePathInfo.wildcardCount > 0) {
3153
+ const listIndex = getListIndexByBindingInfo(bindingInfo);
3154
+ if (listIndex === null) {
3155
+ raiseError(`Cannot resolve state address for binding with wildcard statePathName "${bindingInfo.statePathName}" because list index is null.`);
3156
+ }
3157
+ stateAddress = createStateAddress(bindingInfo.statePathInfo, listIndex);
3158
+ }
3159
+ else {
3160
+ stateAddress = createStateAddress(bindingInfo.statePathInfo, null);
3161
+ }
3162
+ stateAddressByBindingInfo.set(bindingInfo, stateAddress);
3163
+ return stateAddress;
3164
+ }
3165
+ // call for change loopContext
3166
+ function clearStateAddressByBindingInfo(bindingInfo) {
3167
+ stateAddressByBindingInfo.delete(bindingInfo);
3168
+ }
3014
3169
 
3015
3170
  const recursiveBindingTypes = new Set(['if', 'elseif', 'else', 'for']);
3016
3171
  class Content {
@@ -3123,14 +3278,27 @@ function hydrateSetLastNode(node, lastNode) {
3123
3278
  function getPooledContents(bindingInfo) {
3124
3279
  return pooledContentsByNode.get(bindingInfo.node) || [];
3125
3280
  }
3281
+ // プールの上限(アンカーごと)。プールはアンカー(文書に永続するコメントノード)
3282
+ // から content とその DOM サブツリー・バインディング群を強参照するため、無制限だと
3283
+ // 大きなリストのクリア後もメモリが解放されない(10k 行で 10MB 級)。上限超過分は
3284
+ // contentSetByNode の台帳からも外して GC 可能にする。再追加時は createContent で
3285
+ // 作り直すコストと引き換えになる。
3286
+ const MAX_POOLED_CONTENTS = 1000;
3287
+ let maxPooledContents = MAX_POOLED_CONTENTS;
3126
3288
  function setPooledContent(bindingInfo, content) {
3127
- const contents = pooledContentsByNode.get(bindingInfo.node);
3289
+ let contents = pooledContentsByNode.get(bindingInfo.node);
3128
3290
  if (typeof contents === 'undefined') {
3129
- pooledContentsByNode.set(bindingInfo.node, [content]);
3291
+ contents = [];
3292
+ pooledContentsByNode.set(bindingInfo.node, contents);
3130
3293
  }
3131
- else {
3294
+ if (contents.length < maxPooledContents) {
3132
3295
  contents.push(content);
3133
3296
  }
3297
+ else {
3298
+ // 上限超過: content を完全に手放す。contentSetByNode は createContent 時に
3299
+ // 追加されたきり解放経路が無いため、ここで外さないと GC できない。
3300
+ deleteContentByNode(bindingInfo.node, content);
3301
+ }
3134
3302
  }
3135
3303
  function isOnlyNodeInParentContent(firstNode, lastNode) {
3136
3304
  let prevCheckNode = firstNode.previousSibling;
@@ -3154,6 +3322,23 @@ function isOnlyNodeInParentContent(firstNode, lastNode) {
3154
3322
  }
3155
3323
  return onlyNode;
3156
3324
  }
3325
+ // A stable content may be left in place only when its first node verifiably
3326
+ // follows the settled walk position in the same tree: the listIndexes ledger
3327
+ // can lag the physical DOM (element-write swaps reorder listIndexes without
3328
+ // moving nodes; hidden regions unmount contents that stay registered). Empty
3329
+ // contents (null firstNode) always take the settle walk so their mount
3330
+ // bookkeeping matches the pre-LIS behavior.
3331
+ function isPhysicallyAfter(lastNode, firstNode) {
3332
+ if (firstNode === null) {
3333
+ return false;
3334
+ }
3335
+ if (lastNode.nextSibling === firstNode) {
3336
+ return true;
3337
+ }
3338
+ const position = lastNode.compareDocumentPosition(firstNode);
3339
+ return (position & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
3340
+ && (position & Node.DOCUMENT_POSITION_DISCONNECTED) === 0;
3341
+ }
3157
3342
  function getContent(node, listIndex) {
3158
3343
  let contentByListIndex = contentByListIndexByNode.get(node);
3159
3344
  if (typeof contentByListIndex === 'undefined') {
@@ -3213,6 +3398,11 @@ function applyChangeToFor(bindingInfo, context, newValue) {
3213
3398
  let lastNode = bindingInfo.node;
3214
3399
  const elementPathInfo = getPathInfo(listPathInfo.path + '.' + WILDCARD);
3215
3400
  const loopContextStack = context.stateElement.loopContextStack;
3401
+ // When the new order contains inversions, contents in the stable set (an LIS
3402
+ // of old positions) keep their relative order and must not be moved; moving
3403
+ // only the rest avoids the cascade where one swap relocates every row in
3404
+ // between. null = no inversions; the position guard below then does no moves.
3405
+ const stableIndexSet = computeStableIndexSet(diff);
3216
3406
  let fragment = null;
3217
3407
  if (diff.newIndexes.length == diff.addIndexSet.size
3218
3408
  && diff.newIndexes.length > 0
@@ -3282,7 +3472,13 @@ function applyChangeToFor(bindingInfo, context, newValue) {
3282
3472
  if (content === null) {
3283
3473
  raiseError(`Content not found for ListIndex: ${index.index} at path "${listPathInfo.path}"`);
3284
3474
  }
3285
- if (lastNode.nextSibling !== content.firstNode) {
3475
+ // Stable contents are already in correct relative order — but only
3476
+ // trust that after physical verification (see isPhysicallyAfter).
3477
+ // Contents out of order (and everything unverifiable) settle via the
3478
+ // self-healing mountAfter walk below.
3479
+ const stable = stableIndexSet !== null && stableIndexSet.has(index)
3480
+ && isPhysicallyAfter(lastNode, content.firstNode);
3481
+ if (!stable && lastNode.nextSibling !== content.firstNode) {
3286
3482
  content.mountAfter(lastNode);
3287
3483
  }
3288
3484
  }
@@ -3611,8 +3807,14 @@ function applyChangeToStyle(binding, _context, newValue) {
3611
3807
 
3612
3808
  const ssrWrappedNodes = new WeakSet();
3613
3809
  function applyChangeToText(binding, _context, newValue) {
3614
- if (binding.replaceNode.nodeValue !== newValue) {
3615
- binding.replaceNode.nodeValue = newValue;
3810
+ // nodeValue nullable DOMString(実ブラウザでは null / undefined とも空文字に
3811
+ // 正規化される)ため、比較前に同じ規則で文字列化する。生値のまま比較すると
3812
+ // 数値など非文字列値は常に不一致になり、同値でも毎回 DOM 書き込みが走る。
3813
+ // 注: happy-dom は undefined を "undefined" にする非準拠実装なので String() に
3814
+ // 頼らず明示的に "" へ正規化する。
3815
+ const text = newValue === null || newValue === undefined ? "" : String(newValue);
3816
+ if (binding.replaceNode.nodeValue !== text) {
3817
+ binding.replaceNode.nodeValue = text;
3616
3818
  }
3617
3819
  // SSR モード時: テキストノードの前後にコメントを挿入して境界を明示
3618
3820
  if (inSsr() && !ssrWrappedNodes.has(binding.replaceNode)) {
@@ -3783,15 +3985,20 @@ function applyChange(binding, context) {
3783
3985
  return;
3784
3986
  }
3785
3987
  context.appliedBindingSet.add(binding);
3786
- const absAddress = getAbsoluteStateAddressByBinding(binding);
3787
- if (context.updatedAbsAddressSetByStateElement.has(context.stateElement)) {
3788
- const addressSet = context.updatedAbsAddressSetByStateElement.get(context.stateElement);
3789
- addressSet.add(absAddress);
3790
- }
3791
- else {
3792
- context.updatedAbsAddressSetByStateElement.set(context.stateElement, new Set([
3793
- absAddress
3794
- ]));
3988
+ // $updatedCallback が定義されていない state では、更新アドレスの集計自体が
3989
+ // 不要(drain 終端の呼び出しごと省略される)。大量バインディング適用時の
3990
+ // Set 蓄積を避ける。undefined(テスト用モック等)は従来通り集計する。
3991
+ if (context.stateElement.hasUpdatedCallback !== false) {
3992
+ const absAddress = getAbsoluteStateAddressByBinding(binding);
3993
+ if (context.updatedAbsAddressSetByStateElement.has(context.stateElement)) {
3994
+ const addressSet = context.updatedAbsAddressSetByStateElement.get(context.stateElement);
3995
+ addressSet.add(absAddress);
3996
+ }
3997
+ else {
3998
+ context.updatedAbsAddressSetByStateElement.set(context.stateElement, new Set([
3999
+ absAddress
4000
+ ]));
4001
+ }
3795
4002
  }
3796
4003
  if (binding.bindingType === "event") {
3797
4004
  return;
@@ -3807,6 +4014,14 @@ function applyChange(binding, context) {
3807
4014
  return;
3808
4015
  }
3809
4016
  }
4017
+ // applyChangeFromBindings のグループ化ループが解決済みルートの一致を検証済みの
4018
+ // 場合、stateName さえ一致すれば getRootNode の再解決(native 呼び出し)を省略
4019
+ // できる。activateContent 経由(フラグメント内の新規 content)も、フラグメントは
4020
+ // setRootNodeByFragment で context.rootNode に解決されるため同じ不変条件が成り立つ。
4021
+ if (context.sameRootVerified === true && binding.stateName === context.stateName) {
4022
+ _applyChange(binding, context);
4023
+ return;
4024
+ }
3810
4025
  let rootNode = binding.replaceNode.getRootNode();
3811
4026
  if (rootNode instanceof DocumentFragment && !(rootNode instanceof ShadowRoot)) {
3812
4027
  rootNode = getRootNodeByFragment(rootNode);
@@ -3887,6 +4102,9 @@ function applyChangeFromBindings(bindings) {
3887
4102
  newListValueByAbsAddress: newListValueByAbsAddress,
3888
4103
  updatedAbsAddressSetByStateElement: updatedAbsAddressSetByStateElement,
3889
4104
  deferredSelectBindings: deferredSelectBindings,
4105
+ // グループ内の binding は下の do/while が「解決済みルート === rootNode」を
4106
+ // 検証してから applyChange に渡す(applyChange 側の getRootNode 省略の根拠)
4107
+ sameRootVerified: true,
3890
4108
  };
3891
4109
  do {
3892
4110
  applyChange(binding, context);
@@ -3917,7 +4135,8 @@ function applyChangeFromBindings(bindings) {
3917
4135
  }
3918
4136
 
3919
4137
  const handlerByHandlerKey$1 = new Map();
3920
- const bindingSetByHandlerKey$1 = new Map();
4138
+ // binding を強参照しない台帳(handlerBindingRegistry.ts のリーク解説を参照)
4139
+ const bindingRegistry$1 = createHandlerBindingRegistry();
3921
4140
  function getHandlerKey$1(binding, eventName) {
3922
4141
  const filterKey = binding.inFilters.map(f => f.filterName + '(' + f.args.join(',') + ')').join('|');
3923
4142
  return `${binding.stateName}::${binding.statePathName}::${eventName}::${filterKey}`;
@@ -3971,21 +4190,15 @@ function attachRadioEventHandler(binding) {
3971
4190
  handlerByHandlerKey$1.set(key, radioEventHandler);
3972
4191
  }
3973
4192
  binding.node.addEventListener(eventName, radioEventHandler);
3974
- let bindingSet = bindingSetByHandlerKey$1.get(key);
3975
- if (typeof bindingSet === "undefined") {
3976
- bindingSet = new Set([binding]);
3977
- bindingSetByHandlerKey$1.set(key, bindingSet);
3978
- }
3979
- else {
3980
- bindingSet.add(binding);
3981
- }
4193
+ bindingRegistry$1.add(key, binding);
3982
4194
  return true;
3983
4195
  }
3984
4196
  return false;
3985
4197
  }
3986
4198
 
3987
4199
  const handlerByHandlerKey = new Map();
3988
- const bindingSetByHandlerKey = new Map();
4200
+ // binding を強参照しない台帳(handlerBindingRegistry.ts のリーク解説を参照)
4201
+ const bindingRegistry = createHandlerBindingRegistry();
3989
4202
  function getHandlerKey(binding, eventName) {
3990
4203
  const filterKey = binding.inFilters.map(f => f.filterName + '(' + f.args.join(',') + ')').join('|');
3991
4204
  return `${binding.stateName}::${binding.statePathName}::${eventName}::${filterKey}`;
@@ -4058,14 +4271,7 @@ function attachCheckboxEventHandler(binding) {
4058
4271
  handlerByHandlerKey.set(key, checkboxEventHandler);
4059
4272
  }
4060
4273
  binding.node.addEventListener(eventName, checkboxEventHandler);
4061
- let bindingSet = bindingSetByHandlerKey.get(key);
4062
- if (typeof bindingSet === "undefined") {
4063
- bindingSet = new Set([binding]);
4064
- bindingSetByHandlerKey.set(key, bindingSet);
4065
- }
4066
- else {
4067
- bindingSet.add(binding);
4068
- }
4274
+ bindingRegistry.add(key, binding);
4069
4275
  return true;
4070
4276
  }
4071
4277
  return false;
@@ -6096,7 +6302,12 @@ class Updater {
6096
6302
  const absoluteAddressSet = new Set(absoluteAddresses);
6097
6303
  const processBindings = [];
6098
6304
  for (const absoluteAddress of absoluteAddressSet) {
6099
- const bindings = getBindingSetByAbsoluteStateAddress(absoluteAddress);
6305
+ // peek: バインディングの無いアドレス(リスト置換で enqueue される中間
6306
+ // アドレス等)に空 Set を生成・蓄積しない
6307
+ const bindings = peekBindingSetByAbsoluteStateAddress(absoluteAddress);
6308
+ if (bindings === undefined) {
6309
+ continue;
6310
+ }
6100
6311
  for (const binding of bindings) {
6101
6312
  if (binding.replaceNode.isConnected === false) {
6102
6313
  // 切断されているバインディングは無視
@@ -6892,7 +7103,8 @@ function dirtyCacheEntryByAbsoluteStateAddress(address) {
6892
7103
  function checkDependency(handler, address) {
6893
7104
  // 動的依存関係の登録
6894
7105
  if (handler.addressStackLength > 0) {
6895
- const lastInfo = handler.lastAddressStack?.pathInfo ?? null;
7106
+ const lastAddress = handler.lastAddressStack;
7107
+ const lastInfo = lastAddress?.pathInfo ?? null;
6896
7108
  const stateElement = handler.stateElement;
6897
7109
  if (lastInfo !== null) {
6898
7110
  if (stateElement.getterPaths.has(lastInfo.path) &&
@@ -6900,6 +7112,27 @@ function checkDependency(handler, address) {
6900
7112
  // lastInfo.pathはgetterの名前であり、address.pathInfo.pathは
6901
7113
  // そのgetterが参照している値のパスである
6902
7114
  stateElement.addDynamicDependency(address.pathInfo.path, lastInfo.path);
7115
+ // 他行読み取りの検出: 評価中の getter と読み取り先が同じワイルドカード親
7116
+ // (リスト)を共有し、その階層の listIndex が異なる場合、この getter は
7117
+ // 自行の外に依存する(隣接項目参照など)。該当リストを crossRowListPaths に
7118
+ // 記録し、walkDependency の diff-filter 展開を全行展開へフォールバックさせる。
7119
+ if (address.pathInfo.wildcardCount > 0 && lastInfo.wildcardCount > 0) {
7120
+ const sharedLen = calcWildcardLen(address.pathInfo, lastInfo);
7121
+ if (sharedLen > 0) {
7122
+ let crossRow = false;
7123
+ for (let level = 0; level < sharedLen; level++) {
7124
+ if (address.listIndex?.at(level) !== lastAddress.listIndex?.at(level)) {
7125
+ crossRow = true;
7126
+ break;
7127
+ }
7128
+ }
7129
+ if (crossRow) {
7130
+ for (let level = 0; level < sharedLen; level++) {
7131
+ stateElement.addCrossRowListPath?.(address.pathInfo.wildcardParentPaths[level]);
7132
+ }
7133
+ }
7134
+ }
7135
+ }
6903
7136
  }
6904
7137
  }
6905
7138
  }
@@ -7102,6 +7335,37 @@ function _walkExpandWildcard(context, currentWildcardIndex, parentListIndex) {
7102
7335
  }
7103
7336
  }
7104
7337
  }
7338
+ /**
7339
+ * 静的子展開で訪問する listIndex 群を選ぶ。"diff" でも次の場合は全行に倒す:
7340
+ * - diff に変化が一切見えない再代入(同一参照および内容同一コピーの再代入。
7341
+ * `arr[0].v = 5; s.items = [...arr]` のような in-place 変異後のリフレッシュ
7342
+ * イディオムは diff に映らないため、全行展開で従来挙動を保つ。
7343
+ * 削除だけの置換は除く — 残存行に変化は無く、集計はコンテナ動的エッジが担う)
7344
+ * - 他行を読む getter が検出されたリスト(隣接項目参照など。未変更行の派生値も変わりうる)
7345
+ */
7346
+ function selectExpansionIndexes(context, sourcePath, _lastValue, _newValue, listDiff) {
7347
+ if (context.listExpansion === "full") {
7348
+ return listDiff.newIndexes;
7349
+ }
7350
+ if (context.stateElement.crossRowListPaths?.has(sourcePath)) {
7351
+ return listDiff.newIndexes;
7352
+ }
7353
+ if (listDiff.addIndexSet.size === 0 && listDiff.changeIndexSet.size === 0) {
7354
+ // 追加も移動も無い。削除も無ければ「変化が見えない再代入」= リフレッシュ意図
7355
+ if (listDiff.deleteIndexSet.size === 0) {
7356
+ return listDiff.newIndexes;
7357
+ }
7358
+ // 削除のみ: 残存行は位置も値も不変なので展開しない
7359
+ return listDiff.changeIndexSet;
7360
+ }
7361
+ if (listDiff.addIndexSet.size === 0) {
7362
+ return listDiff.changeIndexSet;
7363
+ }
7364
+ if (listDiff.changeIndexSet.size === 0) {
7365
+ return listDiff.addIndexSet;
7366
+ }
7367
+ return [...listDiff.addIndexSet, ...listDiff.changeIndexSet];
7368
+ }
7105
7369
  function _walkDependency(context, startAddress, callback) {
7106
7370
  const stack = [{ address: startAddress, depth: 0 }];
7107
7371
  while (stack.length > 0) {
@@ -7134,7 +7398,7 @@ function _walkDependency(context, startAddress, callback) {
7134
7398
  const absAddress = createAbsoluteStateAddress(absPathInfo, address.listIndex);
7135
7399
  const lastValue = getLastListValueByAbsoluteStateAddress(absAddress);
7136
7400
  const listDiff = createListDiff(address.listIndex, lastValue, newValue);
7137
- for (const listIndex of listDiff.newIndexes) {
7401
+ for (const listIndex of selectExpansionIndexes(context, sourcePath, lastValue, newValue, listDiff)) {
7138
7402
  const depAddress = createStateAddress(depPathInfo, listIndex);
7139
7403
  context.result.add(depAddress);
7140
7404
  nextEntries.push({ address: depAddress, depth: nextDepth });
@@ -7228,7 +7492,7 @@ function _walkDependency(context, startAddress, callback) {
7228
7492
  }
7229
7493
  }
7230
7494
  }
7231
- function walkDependency(stateName, stateElement, startAddress, staticDependency, dynamicDependency, listPathSet, stateProxy, searchType, callback) {
7495
+ function walkDependency(stateName, stateElement, startAddress, staticDependency, dynamicDependency, listPathSet, stateProxy, searchType, callback, options) {
7232
7496
  const context = {
7233
7497
  stateElement: stateElement,
7234
7498
  staticMap: staticDependency,
@@ -7238,6 +7502,7 @@ function walkDependency(stateName, stateElement, startAddress, staticDependency,
7238
7502
  visited: new Set(),
7239
7503
  stateProxy: stateProxy,
7240
7504
  searchType: searchType,
7505
+ listExpansion: options?.listExpansion ?? "full",
7241
7506
  };
7242
7507
  _walkDependency(context, startAddress, callback);
7243
7508
  return Array.from(context.result);
@@ -7303,7 +7568,10 @@ function _setByAddress(target, address, absAddress, value, receiver, handler) {
7303
7568
  dirtyCacheEntryByAbsoluteStateAddress(absDepAddress);
7304
7569
  // 更新対象として登録
7305
7570
  updater.enqueueAbsoluteAddress(absDepAddress);
7306
- });
7571
+ },
7572
+ // リスト置換時は追加行・位置変更行のみ展開する(未変更行の再訪を省く。
7573
+ // $postUpdate の手動リフレッシュは従来通り全行展開のまま)
7574
+ { listExpansion: "diff" });
7307
7575
  }
7308
7576
  }
7309
7577
  function _setByAddressWithSwap(target, address, absAddress, value, receiver, handler) {
@@ -8382,6 +8650,10 @@ class State extends HTMLElement {
8382
8650
  return getBindingsReady(rootNode);
8383
8651
  }
8384
8652
  __state;
8653
+ _hasUpdatedCallback = false;
8654
+ // 他行を読む getter が検出されたリストパス(diff-filter 展開の全行フォールバック対象)。
8655
+ // 依存マップ(static/dynamic)と同様に追加のみ・クリアしない(安全側に固定される)。
8656
+ _crossRowListPaths = new Set();
8385
8657
  _name = 'default';
8386
8658
  _initialized = false;
8387
8659
  _initializePromise;
@@ -8443,6 +8715,13 @@ class State extends HTMLElement {
8443
8715
  this._commandTokenNames = processCommandTokensDeclaration(value);
8444
8716
  this._eventTokenNames = processEventTokensDeclaration(value);
8445
8717
  this.__state = value;
8718
+ // $updatedCallback の有無を state セット時に確定しておく(in はプロトタイプ
8719
+ // チェーンも見る・getter を評価しない)。drain 側はこのフラグで更新アドレスの
8720
+ // 集計と writable createState をスキップできる。
8721
+ // 注: state セット後に生オブジェクトへ直接 $updatedCallback を後付けする
8722
+ // パターンは検知できない(bindProperty / _state 再セットは検知する)。
8723
+ // ライフサイクルフックは宣言時に定義するのが規約。
8724
+ this._hasUpdatedCallback = STATE_UPDATED_CALLBACK_NAME in value;
8446
8725
  // 再 set 時に二重 subscribe しないよう registry をクリアしてから $on を配線し直す。
8447
8726
  clearEventTokenRegistry(this);
8448
8727
  processOnDeclaration(this, value, this._eventTokenNames);
@@ -8865,8 +9144,20 @@ class State extends HTMLElement {
8865
9144
  this._version++;
8866
9145
  return this._version;
8867
9146
  }
9147
+ get hasUpdatedCallback() {
9148
+ return this._hasUpdatedCallback;
9149
+ }
9150
+ get crossRowListPaths() {
9151
+ return this._crossRowListPaths;
9152
+ }
9153
+ addCrossRowListPath(path) {
9154
+ this._crossRowListPaths.add(path);
9155
+ }
8868
9156
  bindProperty(prop, desc) {
8869
9157
  Object.defineProperty(this._state, prop, desc);
9158
+ if (prop === STATE_UPDATED_CALLBACK_NAME) {
9159
+ this._hasUpdatedCallback = true;
9160
+ }
8870
9161
  }
8871
9162
  setInitialState(state) {
8872
9163
  if (!this._initialized) {