@wcstack/state 1.18.0 → 1.19.1

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
@@ -1,10 +1,12 @@
1
- let _inSsrCache = null;
2
1
  function inSsr() {
3
- if (_inSsrCache !== null)
4
- return _inSsrCache;
5
- const html = document.querySelector('html');
6
- _inSsrCache = html ? html.hasAttribute('data-wcs-server') : false;
7
- return _inSsrCache;
2
+ // キャッシュしない: SSR モードはプロセスの属性ではなく「現在の document」の
3
+ // 属性。@wcstack/server はグローバル document を差し替えてサーバーレンダリング
4
+ // した後、同一プロセスでクライアント側ハイドレーションが走る(SSR→hydrate
5
+ // e2e が該当)。サーバーフェーズの判定をキャッシュするとクライアントフェーズが
6
+ // SSR モード扱いになり、hydrateBindings の代わりに buildBindings が選ばれて
7
+ // connectedCallbackPromise が永久に未解決になる。
8
+ const html = document.documentElement;
9
+ return html ? html.hasAttribute('data-wcs-server') : false;
8
10
  }
9
11
  const _config = {
10
12
  bindAttributeName: 'data-wcs',
@@ -63,7 +65,7 @@ function setConfig(partialConfig) {
63
65
  }
64
66
  }
65
67
 
66
- var version$1 = "1.18.0";
68
+ var version$1 = "1.19.1";
67
69
  var pkg = {
68
70
  version: version$1};
69
71
 
@@ -1846,13 +1848,62 @@ const connectedCallbackSymbol = Symbol("$$connectedCallback");
1846
1848
  const disconnectedCallbackSymbol = Symbol("$$disconnectedCallback");
1847
1849
  const updatedCallbackSymbol = Symbol("$$updatedCallback");
1848
1850
 
1851
+ function createHandlerBindingRegistry() {
1852
+ const attachedByKey = new Map();
1853
+ const countByKey = new Map();
1854
+ return {
1855
+ add(key, binding) {
1856
+ let attached = attachedByKey.get(key);
1857
+ if (typeof attached === "undefined") {
1858
+ attached = new WeakSet();
1859
+ attachedByKey.set(key, attached);
1860
+ }
1861
+ if (attached.has(binding)) {
1862
+ return false;
1863
+ }
1864
+ attached.add(binding);
1865
+ countByKey.set(key, (countByKey.get(key) ?? 0) + 1);
1866
+ return true;
1867
+ },
1868
+ remove(key, binding) {
1869
+ const attached = attachedByKey.get(key);
1870
+ if (typeof attached === "undefined" || !attached.has(binding)) {
1871
+ return false;
1872
+ }
1873
+ attached.delete(binding);
1874
+ const next = (countByKey.get(key) ?? 1) - 1;
1875
+ if (next <= 0) {
1876
+ attachedByKey.delete(key);
1877
+ countByKey.delete(key);
1878
+ return true;
1879
+ }
1880
+ countByKey.set(key, next);
1881
+ return false;
1882
+ },
1883
+ has(key, binding) {
1884
+ return attachedByKey.get(key)?.has(binding) ?? false;
1885
+ },
1886
+ countOf(key) {
1887
+ return countByKey.get(key) ?? 0;
1888
+ },
1889
+ get keyCount() {
1890
+ return countByKey.size;
1891
+ },
1892
+ clear() {
1893
+ attachedByKey.clear();
1894
+ countByKey.clear();
1895
+ },
1896
+ };
1897
+ }
1898
+
1849
1899
  // onclick: $command.<name> のように、DOM イベントから command token を直接 emit する形式かを判定する。
1850
1900
  // 右辺が $command 名前空間配下のパス($command.<token>)のときに true。
1851
1901
  function isCommandTokenPath(statePathName) {
1852
1902
  return statePathName.startsWith(STATE_COMMAND_NAMESPACE_NAME + ".");
1853
1903
  }
1854
1904
  const handlerByHandlerKey$3 = new Map();
1855
- const bindingSetByHandlerKey$3 = new Map();
1905
+ // binding を強参照しない台帳(handlerBindingRegistry.ts のリーク解説を参照)
1906
+ const bindingRegistry$3 = createHandlerBindingRegistry();
1856
1907
  function getHandlerKey$3(binding) {
1857
1908
  const modifierKey = binding.propModifiers.filter(m => m === 'prevent' || m === 'stop').sort().join(',');
1858
1909
  return `${binding.stateName}::${binding.statePathName}::${modifierKey}`;
@@ -1901,14 +1952,7 @@ function attachEventHandler(binding) {
1901
1952
  }
1902
1953
  const eventName = binding.propName.slice(2);
1903
1954
  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
- }
1955
+ bindingRegistry$3.add(key, binding);
1912
1956
  return true;
1913
1957
  }
1914
1958
 
@@ -2078,7 +2122,8 @@ function isPossibleTwoWay(node, propName) {
2078
2122
  }
2079
2123
 
2080
2124
  const handlerByHandlerKey$2 = new Map();
2081
- const bindingSetByHandlerKey$2 = new Map();
2125
+ // binding を強参照しない台帳(handlerBindingRegistry.ts のリーク解説を参照)
2126
+ const bindingRegistry$2 = createHandlerBindingRegistry();
2082
2127
  const DEFAULT_GETTER = (e) => e.detail;
2083
2128
  function getHandlerKey$2(binding, eventName, hasGetter) {
2084
2129
  const filterKey = binding.inFilters.map(f => f.filterName + '(' + f.args.join(',') + ')').join('|');
@@ -2181,14 +2226,7 @@ function attachTwowayEventHandler(binding) {
2181
2226
  handlerByHandlerKey$2.set(key, twowayEventHandler);
2182
2227
  }
2183
2228
  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
- }
2229
+ bindingRegistry$2.add(key, binding);
2192
2230
  }
2193
2231
  }
2194
2232
 
@@ -2752,6 +2790,20 @@ function isSameList(oldList, newList) {
2752
2790
  }
2753
2791
  return true;
2754
2792
  }
2793
+ /**
2794
+ * Aligns each list index's .index with its position in the new list.
2795
+ * A diff only becomes the rendered state once the updater applies it: an
2796
+ * earlier diff in the same batch (two replacements in one microtask) may have
2797
+ * moved shared indexes toward a list that never got applied, and a cache hit
2798
+ * skips recomputation entirely — so every createListDiff return re-aligns.
2799
+ */
2800
+ function syncListIndexes(newIndexes) {
2801
+ for (let i = 0; i < newIndexes.length; i++) {
2802
+ if (newIndexes[i].index !== i) {
2803
+ newIndexes[i].index = i;
2804
+ }
2805
+ }
2806
+ }
2755
2807
  /**
2756
2808
  * Creates or updates list indexes by comparing old and new lists.
2757
2809
  * Optimizes by reusing existing list indexes when values match.
@@ -2762,6 +2814,11 @@ function isSameList(oldList, newList) {
2762
2814
  * @returns Array of list indexes for the new list
2763
2815
  */
2764
2816
  function createListDiff(parentListIndex, rawOldList, rawNewList) {
2817
+ const diff = computeListDiff(parentListIndex, rawOldList, rawNewList);
2818
+ syncListIndexes(diff.newIndexes);
2819
+ return diff;
2820
+ }
2821
+ function computeListDiff(parentListIndex, rawOldList, rawNewList) {
2765
2822
  // Normalize inputs to arrays (handles null/undefined)
2766
2823
  const oldList = (Array.isArray(rawOldList) && rawOldList.length > 0) ? rawOldList : EMPTY_LIST;
2767
2824
  const newList = (Array.isArray(rawNewList) && rawNewList.length > 0) ? rawNewList : EMPTY_LIST;
@@ -2810,6 +2867,10 @@ function createListDiff(parentListIndex, rawOldList, rawNewList) {
2810
2867
  addIndexSet: EMPTY_SET$1,
2811
2868
  };
2812
2869
  }
2870
+ if (newIndexes !== null) {
2871
+ return calcDiffIndexes(oldIndexes, newIndexes);
2872
+ }
2873
+ newIndexes = [];
2813
2874
  // Use index-based map for efficiency
2814
2875
  // Supports duplicate values by storing array of indexes
2815
2876
  const indexByValue = new Map();
@@ -2822,10 +2883,6 @@ function createListDiff(parentListIndex, rawOldList, rawNewList) {
2822
2883
  }
2823
2884
  indexes.push(i);
2824
2885
  }
2825
- if (newIndexes !== null) {
2826
- return calcDiffIndexes(oldList, newList, oldIndexes, newIndexes, indexByValue);
2827
- }
2828
- newIndexes = [];
2829
2886
  // Build new indexes array by matching values with old list
2830
2887
  const changeIndexSet = new Set();
2831
2888
  const addIndexSet = new Set();
@@ -2842,9 +2899,11 @@ function createListDiff(parentListIndex, rawOldList, rawNewList) {
2842
2899
  else {
2843
2900
  // Reuse existing element
2844
2901
  const existingListIndex = oldIndexes[oldIndex];
2845
- // Update index if position changed
2846
- if (existingListIndex.index !== i) {
2847
- existingListIndex.index = i;
2902
+ // Judge position change against the old list's order (oldIndexes array
2903
+ // order), not the mutable .index an earlier diff in the same batch
2904
+ // may have already moved .index toward a list that was never applied.
2905
+ // The .index itself is re-aligned by syncListIndexes on return.
2906
+ if (oldIndex !== i) {
2848
2907
  changeIndexSet.add(existingListIndex);
2849
2908
  }
2850
2909
  newIndexes.push(existingListIndex);
@@ -2866,22 +2925,35 @@ function createListDiff(parentListIndex, rawOldList, rawNewList) {
2866
2925
  }
2867
2926
  }
2868
2927
  }
2869
- function calcDiffIndexes(oldList, newList, oldIndexes, newIndexes, indexByValue) {
2928
+ /**
2929
+ * Diff between two lists whose listIndex ledgers both already exist.
2930
+ * Rows are joined by listIndex identity — the same key applyChangeToFor and
2931
+ * walkDependency consume the result sets with. A value-based join could mark
2932
+ * oldIndexes-side objects that are absent from newIndexes (ledgers built along
2933
+ * unconnected diff chains hold different objects for the same value); such
2934
+ * orphan markers never match the consumers' has() lookups and only pollute
2935
+ * the dirty set. Rows without shared identity are represented as add+delete.
2936
+ */
2937
+ function calcDiffIndexes(oldIndexes, newIndexes) {
2870
2938
  const newIndexSet = new Set(newIndexes);
2871
2939
  const oldIndexSet = new Set(oldIndexes);
2872
2940
  const changeIndexSet = new Set();
2873
2941
  const addIndexSet = newIndexSet.difference(oldIndexSet);
2874
2942
  const deleteIndexSet = oldIndexSet.difference(newIndexSet);
2875
- for (let i = 0; i < newList.length; i++) {
2876
- const newValue = newList[i];
2877
- const existingIndexes = indexByValue.get(newValue);
2878
- const oldIndex = existingIndexes && existingIndexes.length > 0 ? existingIndexes.shift() : undefined;
2879
- if (typeof oldIndex !== "undefined") {
2880
- const existingListIndex = oldIndexes[oldIndex];
2881
- if (existingListIndex.index !== i) {
2882
- // 位置が違うことだけを記録
2883
- changeIndexSet.add(existingListIndex);
2884
- }
2943
+ // Old positions come from the oldIndexes array order (.index may have been
2944
+ // mutated by an unapplied diff in the same batch).
2945
+ const oldPosByIndex = new Map();
2946
+ for (let i = 0; i < oldIndexes.length; i++) {
2947
+ oldPosByIndex.set(oldIndexes[i], i);
2948
+ }
2949
+ for (let i = 0; i < newIndexes.length; i++) {
2950
+ const index = newIndexes[i];
2951
+ if (addIndexSet.has(index)) {
2952
+ continue;
2953
+ }
2954
+ if (oldPosByIndex.get(index) !== i) {
2955
+ // 位置が違うことだけを記録
2956
+ changeIndexSet.add(index);
2885
2957
  }
2886
2958
  }
2887
2959
  return {
@@ -2893,6 +2965,103 @@ function calcDiffIndexes(oldList, newList, oldIndexes, newIndexes, indexByValue)
2893
2965
  };
2894
2966
  }
2895
2967
 
2968
+ /**
2969
+ * Indices into `seq` whose values form a longest strictly-increasing
2970
+ * subsequence, returned in ascending order. Classic patience-sorting
2971
+ * LIS in O(n log n). `seq` values are assumed distinct (old list
2972
+ * positions are unique).
2973
+ */
2974
+ function longestIncreasingSubsequence(seq) {
2975
+ const n = seq.length;
2976
+ // tails[k] = index into seq of the smallest tail of an increasing
2977
+ // subsequence of length k+1; prev[i] = predecessor index to rebuild the chain.
2978
+ const tails = [];
2979
+ const prev = new Array(n).fill(-1);
2980
+ for (let i = 0; i < n; i++) {
2981
+ const value = seq[i];
2982
+ let lo = 0;
2983
+ let hi = tails.length;
2984
+ while (lo < hi) {
2985
+ const mid = (lo + hi) >> 1;
2986
+ if (seq[tails[mid]] < value) {
2987
+ lo = mid + 1;
2988
+ }
2989
+ else {
2990
+ hi = mid;
2991
+ }
2992
+ }
2993
+ if (lo > 0) {
2994
+ prev[i] = tails[lo - 1];
2995
+ }
2996
+ tails[lo] = i;
2997
+ }
2998
+ const result = [];
2999
+ let k = tails.length > 0 ? tails[tails.length - 1] : -1;
3000
+ while (k >= 0) {
3001
+ result.push(k);
3002
+ k = prev[k];
3003
+ }
3004
+ result.reverse();
3005
+ return result;
3006
+ }
3007
+ /**
3008
+ * Determines which reused list indexes can stay where they are when the DOM
3009
+ * is brought into the new list order.
3010
+ *
3011
+ * Returns null when the reused indexes already appear in their old relative
3012
+ * order (no inversions) — the caller's existing position guard then performs
3013
+ * no moves, so nothing extra is needed. When inversions exist, returns the
3014
+ * set of indexes forming a longest increasing subsequence of old positions:
3015
+ * leaving exactly those in place and moving every other content yields the
3016
+ * correct final order with the fewest content moves (the naive forward walk
3017
+ * otherwise cascades: a single swap of rows 2/999 in 1000 rows moves ~997
3018
+ * contents instead of 2).
3019
+ *
3020
+ * Note: IListIndex.index is already mutated to the NEW position by
3021
+ * createListDiff, so old positions must come from the oldIndexes array order.
3022
+ */
3023
+ function computeStableIndexSet(diff) {
3024
+ // No reused index changed position, or nothing was reused: relative order
3025
+ // is already correct and the walk performs no moves.
3026
+ if (diff.changeIndexSet.size === 0 || diff.addIndexSet.size === diff.newIndexes.length) {
3027
+ return null;
3028
+ }
3029
+ const oldPosByIndex = new Map();
3030
+ for (let i = 0; i < diff.oldIndexes.length; i++) {
3031
+ oldPosByIndex.set(diff.oldIndexes[i], i);
3032
+ }
3033
+ const reused = [];
3034
+ const seq = [];
3035
+ let prevPos = -1;
3036
+ let sorted = true;
3037
+ for (const index of diff.newIndexes) {
3038
+ if (diff.addIndexSet.has(index)) {
3039
+ continue;
3040
+ }
3041
+ const pos = oldPosByIndex.get(index);
3042
+ if (pos === undefined) {
3043
+ // Invariant break (a reused index missing from oldIndexes): fall back
3044
+ // to the settle walk rather than compute a stable set from bad data.
3045
+ return null;
3046
+ }
3047
+ if (pos < prevPos) {
3048
+ sorted = false;
3049
+ }
3050
+ prevPos = pos;
3051
+ reused.push(index);
3052
+ seq.push(pos);
3053
+ }
3054
+ if (sorted) {
3055
+ return null;
3056
+ }
3057
+ const lis = longestIncreasingSubsequence(seq);
3058
+ const stable = new Set();
3059
+ for (const seqIndex of lis) {
3060
+ stable.add(reused[seqIndex]);
3061
+ }
3062
+ return stable;
3063
+ }
3064
+
2896
3065
  const bindingSetByAbsoluteStateAddress = new WeakMap();
2897
3066
  function getBindingSetByAbsoluteStateAddress(absoluteStateAddress) {
2898
3067
  let bindingSet = null;
@@ -2903,38 +3072,24 @@ function getBindingSetByAbsoluteStateAddress(absoluteStateAddress) {
2903
3072
  }
2904
3073
  return bindingSet;
2905
3074
  }
3075
+ /**
3076
+ * 参照専用の取得。get-or-create と違い、未登録アドレスに空 Set を
3077
+ * 生成・キャッシュしない(リスト置換の drain は大量のバインディング無し
3078
+ * アドレスを照会するため、生成すると空 Set が溜まり続ける)。
3079
+ */
3080
+ function peekBindingSetByAbsoluteStateAddress(absoluteStateAddress) {
3081
+ return bindingSetByAbsoluteStateAddress.get(absoluteStateAddress);
3082
+ }
2906
3083
  function addBindingByAbsoluteStateAddress(absoluteStateAddress, binding) {
2907
3084
  const bindingSet = getBindingSetByAbsoluteStateAddress(absoluteStateAddress);
2908
3085
  bindingSet.add(binding);
2909
3086
  }
2910
3087
  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;
3088
+ // get-or-create を通すと未登録アドレスに空 Set を生成してしまうため素の get で参照する
3089
+ const bindingSet = bindingSetByAbsoluteStateAddress.get(absoluteStateAddress);
3090
+ if (bindingSet !== undefined) {
3091
+ bindingSet.delete(binding);
2921
3092
  }
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
3093
  }
2939
3094
 
2940
3095
  const bindingsByContent = new WeakMap();
@@ -2983,8 +3138,10 @@ function deactivateContent(content) {
2983
3138
  for (const binding of bindings) {
2984
3139
  const absoluteStateAddress = getAbsoluteStateAddressByBinding(binding);
2985
3140
  removeBindingByAbsoluteStateAddress(absoluteStateAddress, binding);
2986
- clearAbsoluteStateAddressByBinding(binding);
2987
- clearStateAddressByBindingInfo(binding);
3141
+ // アドレスキャッシュ(absoluteStateAddressByBinding / stateAddressByBindingInfo)
3142
+ // のクリアはここでは行わない。deactivateContent の呼び出し元(for/if)は必ず
3143
+ // 直後に content.unmount() を呼び、unmount が同じ2台帳をネスト content も含めて
3144
+ // クリアする(createContent.ts)。ここで消すと全 binding で二重 delete になる。
2988
3145
  }
2989
3146
  unbindLoopContextToContent(content);
2990
3147
  }
@@ -3011,6 +3168,40 @@ function getContentSetByNode(node) {
3011
3168
  }
3012
3169
  return EMPTY_SET;
3013
3170
  }
3171
+ function deleteContentByNode(node, content) {
3172
+ const contents = contentSetByNode.get(node);
3173
+ if (contents) {
3174
+ contents.delete(content);
3175
+ if (contents.size === 0) {
3176
+ contentSetByNode.delete(node);
3177
+ }
3178
+ }
3179
+ }
3180
+
3181
+ const stateAddressByBindingInfo = new WeakMap();
3182
+ function getStateAddressByBindingInfo(bindingInfo) {
3183
+ let stateAddress = null;
3184
+ stateAddress = stateAddressByBindingInfo.get(bindingInfo) || null;
3185
+ if (stateAddress !== null) {
3186
+ return stateAddress;
3187
+ }
3188
+ if (bindingInfo.statePathInfo.wildcardCount > 0) {
3189
+ const listIndex = getListIndexByBindingInfo(bindingInfo);
3190
+ if (listIndex === null) {
3191
+ raiseError(`Cannot resolve state address for binding with wildcard statePathName "${bindingInfo.statePathName}" because list index is null.`);
3192
+ }
3193
+ stateAddress = createStateAddress(bindingInfo.statePathInfo, listIndex);
3194
+ }
3195
+ else {
3196
+ stateAddress = createStateAddress(bindingInfo.statePathInfo, null);
3197
+ }
3198
+ stateAddressByBindingInfo.set(bindingInfo, stateAddress);
3199
+ return stateAddress;
3200
+ }
3201
+ // call for change loopContext
3202
+ function clearStateAddressByBindingInfo(bindingInfo) {
3203
+ stateAddressByBindingInfo.delete(bindingInfo);
3204
+ }
3014
3205
 
3015
3206
  const recursiveBindingTypes = new Set(['if', 'elseif', 'else', 'for']);
3016
3207
  class Content {
@@ -3123,14 +3314,27 @@ function hydrateSetLastNode(node, lastNode) {
3123
3314
  function getPooledContents(bindingInfo) {
3124
3315
  return pooledContentsByNode.get(bindingInfo.node) || [];
3125
3316
  }
3317
+ // プールの上限(アンカーごと)。プールはアンカー(文書に永続するコメントノード)
3318
+ // から content とその DOM サブツリー・バインディング群を強参照するため、無制限だと
3319
+ // 大きなリストのクリア後もメモリが解放されない(10k 行で 10MB 級)。上限超過分は
3320
+ // contentSetByNode の台帳からも外して GC 可能にする。再追加時は createContent で
3321
+ // 作り直すコストと引き換えになる。
3322
+ const MAX_POOLED_CONTENTS = 1000;
3323
+ let maxPooledContents = MAX_POOLED_CONTENTS;
3126
3324
  function setPooledContent(bindingInfo, content) {
3127
- const contents = pooledContentsByNode.get(bindingInfo.node);
3325
+ let contents = pooledContentsByNode.get(bindingInfo.node);
3128
3326
  if (typeof contents === 'undefined') {
3129
- pooledContentsByNode.set(bindingInfo.node, [content]);
3327
+ contents = [];
3328
+ pooledContentsByNode.set(bindingInfo.node, contents);
3130
3329
  }
3131
- else {
3330
+ if (contents.length < maxPooledContents) {
3132
3331
  contents.push(content);
3133
3332
  }
3333
+ else {
3334
+ // 上限超過: content を完全に手放す。contentSetByNode は createContent 時に
3335
+ // 追加されたきり解放経路が無いため、ここで外さないと GC できない。
3336
+ deleteContentByNode(bindingInfo.node, content);
3337
+ }
3134
3338
  }
3135
3339
  function isOnlyNodeInParentContent(firstNode, lastNode) {
3136
3340
  let prevCheckNode = firstNode.previousSibling;
@@ -3154,6 +3358,23 @@ function isOnlyNodeInParentContent(firstNode, lastNode) {
3154
3358
  }
3155
3359
  return onlyNode;
3156
3360
  }
3361
+ // A stable content may be left in place only when its first node verifiably
3362
+ // follows the settled walk position in the same tree: the listIndexes ledger
3363
+ // can lag the physical DOM (element-write swaps reorder listIndexes without
3364
+ // moving nodes; hidden regions unmount contents that stay registered). Empty
3365
+ // contents (null firstNode) always take the settle walk so their mount
3366
+ // bookkeeping matches the pre-LIS behavior.
3367
+ function isPhysicallyAfter(lastNode, firstNode) {
3368
+ if (firstNode === null) {
3369
+ return false;
3370
+ }
3371
+ if (lastNode.nextSibling === firstNode) {
3372
+ return true;
3373
+ }
3374
+ const position = lastNode.compareDocumentPosition(firstNode);
3375
+ return (position & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
3376
+ && (position & Node.DOCUMENT_POSITION_DISCONNECTED) === 0;
3377
+ }
3157
3378
  function getContent(node, listIndex) {
3158
3379
  let contentByListIndex = contentByListIndexByNode.get(node);
3159
3380
  if (typeof contentByListIndex === 'undefined') {
@@ -3213,6 +3434,11 @@ function applyChangeToFor(bindingInfo, context, newValue) {
3213
3434
  let lastNode = bindingInfo.node;
3214
3435
  const elementPathInfo = getPathInfo(listPathInfo.path + '.' + WILDCARD);
3215
3436
  const loopContextStack = context.stateElement.loopContextStack;
3437
+ // When the new order contains inversions, contents in the stable set (an LIS
3438
+ // of old positions) keep their relative order and must not be moved; moving
3439
+ // only the rest avoids the cascade where one swap relocates every row in
3440
+ // between. null = no inversions; the position guard below then does no moves.
3441
+ const stableIndexSet = computeStableIndexSet(diff);
3216
3442
  let fragment = null;
3217
3443
  if (diff.newIndexes.length == diff.addIndexSet.size
3218
3444
  && diff.newIndexes.length > 0
@@ -3282,7 +3508,13 @@ function applyChangeToFor(bindingInfo, context, newValue) {
3282
3508
  if (content === null) {
3283
3509
  raiseError(`Content not found for ListIndex: ${index.index} at path "${listPathInfo.path}"`);
3284
3510
  }
3285
- if (lastNode.nextSibling !== content.firstNode) {
3511
+ // Stable contents are already in correct relative order — but only
3512
+ // trust that after physical verification (see isPhysicallyAfter).
3513
+ // Contents out of order (and everything unverifiable) settle via the
3514
+ // self-healing mountAfter walk below.
3515
+ const stable = stableIndexSet !== null && stableIndexSet.has(index)
3516
+ && isPhysicallyAfter(lastNode, content.firstNode);
3517
+ if (!stable && lastNode.nextSibling !== content.firstNode) {
3286
3518
  content.mountAfter(lastNode);
3287
3519
  }
3288
3520
  }
@@ -3611,8 +3843,14 @@ function applyChangeToStyle(binding, _context, newValue) {
3611
3843
 
3612
3844
  const ssrWrappedNodes = new WeakSet();
3613
3845
  function applyChangeToText(binding, _context, newValue) {
3614
- if (binding.replaceNode.nodeValue !== newValue) {
3615
- binding.replaceNode.nodeValue = newValue;
3846
+ // nodeValue nullable DOMString(実ブラウザでは null / undefined とも空文字に
3847
+ // 正規化される)ため、比較前に同じ規則で文字列化する。生値のまま比較すると
3848
+ // 数値など非文字列値は常に不一致になり、同値でも毎回 DOM 書き込みが走る。
3849
+ // 注: happy-dom は undefined を "undefined" にする非準拠実装なので String() に
3850
+ // 頼らず明示的に "" へ正規化する。
3851
+ const text = newValue === null || newValue === undefined ? "" : String(newValue);
3852
+ if (binding.replaceNode.nodeValue !== text) {
3853
+ binding.replaceNode.nodeValue = text;
3616
3854
  }
3617
3855
  // SSR モード時: テキストノードの前後にコメントを挿入して境界を明示
3618
3856
  if (inSsr() && !ssrWrappedNodes.has(binding.replaceNode)) {
@@ -3783,15 +4021,20 @@ function applyChange(binding, context) {
3783
4021
  return;
3784
4022
  }
3785
4023
  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
- ]));
4024
+ // $updatedCallback が定義されていない state では、更新アドレスの集計自体が
4025
+ // 不要(drain 終端の呼び出しごと省略される)。大量バインディング適用時の
4026
+ // Set 蓄積を避ける。undefined(テスト用モック等)は従来通り集計する。
4027
+ if (context.stateElement.hasUpdatedCallback !== false) {
4028
+ const absAddress = getAbsoluteStateAddressByBinding(binding);
4029
+ if (context.updatedAbsAddressSetByStateElement.has(context.stateElement)) {
4030
+ const addressSet = context.updatedAbsAddressSetByStateElement.get(context.stateElement);
4031
+ addressSet.add(absAddress);
4032
+ }
4033
+ else {
4034
+ context.updatedAbsAddressSetByStateElement.set(context.stateElement, new Set([
4035
+ absAddress
4036
+ ]));
4037
+ }
3795
4038
  }
3796
4039
  if (binding.bindingType === "event") {
3797
4040
  return;
@@ -3807,6 +4050,14 @@ function applyChange(binding, context) {
3807
4050
  return;
3808
4051
  }
3809
4052
  }
4053
+ // applyChangeFromBindings のグループ化ループが解決済みルートの一致を検証済みの
4054
+ // 場合、stateName さえ一致すれば getRootNode の再解決(native 呼び出し)を省略
4055
+ // できる。activateContent 経由(フラグメント内の新規 content)も、フラグメントは
4056
+ // setRootNodeByFragment で context.rootNode に解決されるため同じ不変条件が成り立つ。
4057
+ if (context.sameRootVerified === true && binding.stateName === context.stateName) {
4058
+ _applyChange(binding, context);
4059
+ return;
4060
+ }
3810
4061
  let rootNode = binding.replaceNode.getRootNode();
3811
4062
  if (rootNode instanceof DocumentFragment && !(rootNode instanceof ShadowRoot)) {
3812
4063
  rootNode = getRootNodeByFragment(rootNode);
@@ -3887,6 +4138,9 @@ function applyChangeFromBindings(bindings) {
3887
4138
  newListValueByAbsAddress: newListValueByAbsAddress,
3888
4139
  updatedAbsAddressSetByStateElement: updatedAbsAddressSetByStateElement,
3889
4140
  deferredSelectBindings: deferredSelectBindings,
4141
+ // グループ内の binding は下の do/while が「解決済みルート === rootNode」を
4142
+ // 検証してから applyChange に渡す(applyChange 側の getRootNode 省略の根拠)
4143
+ sameRootVerified: true,
3890
4144
  };
3891
4145
  do {
3892
4146
  applyChange(binding, context);
@@ -3917,7 +4171,8 @@ function applyChangeFromBindings(bindings) {
3917
4171
  }
3918
4172
 
3919
4173
  const handlerByHandlerKey$1 = new Map();
3920
- const bindingSetByHandlerKey$1 = new Map();
4174
+ // binding を強参照しない台帳(handlerBindingRegistry.ts のリーク解説を参照)
4175
+ const bindingRegistry$1 = createHandlerBindingRegistry();
3921
4176
  function getHandlerKey$1(binding, eventName) {
3922
4177
  const filterKey = binding.inFilters.map(f => f.filterName + '(' + f.args.join(',') + ')').join('|');
3923
4178
  return `${binding.stateName}::${binding.statePathName}::${eventName}::${filterKey}`;
@@ -3971,21 +4226,15 @@ function attachRadioEventHandler(binding) {
3971
4226
  handlerByHandlerKey$1.set(key, radioEventHandler);
3972
4227
  }
3973
4228
  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
- }
4229
+ bindingRegistry$1.add(key, binding);
3982
4230
  return true;
3983
4231
  }
3984
4232
  return false;
3985
4233
  }
3986
4234
 
3987
4235
  const handlerByHandlerKey = new Map();
3988
- const bindingSetByHandlerKey = new Map();
4236
+ // binding を強参照しない台帳(handlerBindingRegistry.ts のリーク解説を参照)
4237
+ const bindingRegistry = createHandlerBindingRegistry();
3989
4238
  function getHandlerKey(binding, eventName) {
3990
4239
  const filterKey = binding.inFilters.map(f => f.filterName + '(' + f.args.join(',') + ')').join('|');
3991
4240
  return `${binding.stateName}::${binding.statePathName}::${eventName}::${filterKey}`;
@@ -4058,14 +4307,7 @@ function attachCheckboxEventHandler(binding) {
4058
4307
  handlerByHandlerKey.set(key, checkboxEventHandler);
4059
4308
  }
4060
4309
  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
- }
4310
+ bindingRegistry.add(key, binding);
4069
4311
  return true;
4070
4312
  }
4071
4313
  return false;
@@ -5460,8 +5702,14 @@ class LoopContextStack {
5460
5702
  }
5461
5703
  }
5462
5704
  else {
5463
- if (loopContext.pathInfo.wildcardCount !== 1) {
5464
- raiseError(`Cannot push loop context for a list with wildcard positions when there is no active loop context.`);
5705
+ // With no active loop context the address must be self-contained: the
5706
+ // listIndex chain supplies one index per wildcard. Top-level lists
5707
+ // (wildcardCount 1) always satisfy this. A nested list re-rendered
5708
+ // directly (e.g. replaced via $resolve from outside the loop) also
5709
+ // satisfies it — the for binding's listIndex carries the full ancestor
5710
+ // chain.
5711
+ if (loopContext.listIndex.length !== loopContext.pathInfo.wildcardCount) {
5712
+ raiseError(`Cannot push loop context when there is no active loop context: the list index chain (length ${loopContext.listIndex.length}) does not cover the wildcard path (wildcard count ${loopContext.pathInfo.wildcardCount}).`);
5465
5713
  }
5466
5714
  }
5467
5715
  this._loopContextStack[this._length] = loopContext;
@@ -6096,7 +6344,12 @@ class Updater {
6096
6344
  const absoluteAddressSet = new Set(absoluteAddresses);
6097
6345
  const processBindings = [];
6098
6346
  for (const absoluteAddress of absoluteAddressSet) {
6099
- const bindings = getBindingSetByAbsoluteStateAddress(absoluteAddress);
6347
+ // peek: バインディングの無いアドレス(リスト置換で enqueue される中間
6348
+ // アドレス等)に空 Set を生成・蓄積しない
6349
+ const bindings = peekBindingSetByAbsoluteStateAddress(absoluteAddress);
6350
+ if (bindings === undefined) {
6351
+ continue;
6352
+ }
6100
6353
  for (const binding of bindings) {
6101
6354
  if (binding.replaceNode.isConnected === false) {
6102
6355
  // 切断されているバインディングは無視
@@ -6892,7 +7145,8 @@ function dirtyCacheEntryByAbsoluteStateAddress(address) {
6892
7145
  function checkDependency(handler, address) {
6893
7146
  // 動的依存関係の登録
6894
7147
  if (handler.addressStackLength > 0) {
6895
- const lastInfo = handler.lastAddressStack?.pathInfo ?? null;
7148
+ const lastAddress = handler.lastAddressStack;
7149
+ const lastInfo = lastAddress?.pathInfo ?? null;
6896
7150
  const stateElement = handler.stateElement;
6897
7151
  if (lastInfo !== null) {
6898
7152
  if (stateElement.getterPaths.has(lastInfo.path) &&
@@ -6900,6 +7154,27 @@ function checkDependency(handler, address) {
6900
7154
  // lastInfo.pathはgetterの名前であり、address.pathInfo.pathは
6901
7155
  // そのgetterが参照している値のパスである
6902
7156
  stateElement.addDynamicDependency(address.pathInfo.path, lastInfo.path);
7157
+ // 他行読み取りの検出: 評価中の getter と読み取り先が同じワイルドカード親
7158
+ // (リスト)を共有し、その階層の listIndex が異なる場合、この getter は
7159
+ // 自行の外に依存する(隣接項目参照など)。該当リストを crossRowListPaths に
7160
+ // 記録し、walkDependency の diff-filter 展開を全行展開へフォールバックさせる。
7161
+ if (address.pathInfo.wildcardCount > 0 && lastInfo.wildcardCount > 0) {
7162
+ const sharedLen = calcWildcardLen(address.pathInfo, lastInfo);
7163
+ if (sharedLen > 0) {
7164
+ let crossRow = false;
7165
+ for (let level = 0; level < sharedLen; level++) {
7166
+ if (address.listIndex?.at(level) !== lastAddress.listIndex?.at(level)) {
7167
+ crossRow = true;
7168
+ break;
7169
+ }
7170
+ }
7171
+ if (crossRow) {
7172
+ for (let level = 0; level < sharedLen; level++) {
7173
+ stateElement.addCrossRowListPath?.(address.pathInfo.wildcardParentPaths[level]);
7174
+ }
7175
+ }
7176
+ }
7177
+ }
6903
7178
  }
6904
7179
  }
6905
7180
  }
@@ -7102,6 +7377,37 @@ function _walkExpandWildcard(context, currentWildcardIndex, parentListIndex) {
7102
7377
  }
7103
7378
  }
7104
7379
  }
7380
+ /**
7381
+ * 静的子展開で訪問する listIndex 群を選ぶ。"diff" でも次の場合は全行に倒す:
7382
+ * - diff に変化が一切見えない再代入(同一参照および内容同一コピーの再代入。
7383
+ * `arr[0].v = 5; s.items = [...arr]` のような in-place 変異後のリフレッシュ
7384
+ * イディオムは diff に映らないため、全行展開で従来挙動を保つ。
7385
+ * 削除だけの置換は除く — 残存行に変化は無く、集計はコンテナ動的エッジが担う)
7386
+ * - 他行を読む getter が検出されたリスト(隣接項目参照など。未変更行の派生値も変わりうる)
7387
+ */
7388
+ function selectExpansionIndexes(context, sourcePath, _lastValue, _newValue, listDiff) {
7389
+ if (context.listExpansion === "full") {
7390
+ return listDiff.newIndexes;
7391
+ }
7392
+ if (context.stateElement.crossRowListPaths?.has(sourcePath)) {
7393
+ return listDiff.newIndexes;
7394
+ }
7395
+ if (listDiff.addIndexSet.size === 0 && listDiff.changeIndexSet.size === 0) {
7396
+ // 追加も移動も無い。削除も無ければ「変化が見えない再代入」= リフレッシュ意図
7397
+ if (listDiff.deleteIndexSet.size === 0) {
7398
+ return listDiff.newIndexes;
7399
+ }
7400
+ // 削除のみ: 残存行は位置も値も不変なので展開しない
7401
+ return listDiff.changeIndexSet;
7402
+ }
7403
+ if (listDiff.addIndexSet.size === 0) {
7404
+ return listDiff.changeIndexSet;
7405
+ }
7406
+ if (listDiff.changeIndexSet.size === 0) {
7407
+ return listDiff.addIndexSet;
7408
+ }
7409
+ return [...listDiff.addIndexSet, ...listDiff.changeIndexSet];
7410
+ }
7105
7411
  function _walkDependency(context, startAddress, callback) {
7106
7412
  const stack = [{ address: startAddress, depth: 0 }];
7107
7413
  while (stack.length > 0) {
@@ -7134,7 +7440,7 @@ function _walkDependency(context, startAddress, callback) {
7134
7440
  const absAddress = createAbsoluteStateAddress(absPathInfo, address.listIndex);
7135
7441
  const lastValue = getLastListValueByAbsoluteStateAddress(absAddress);
7136
7442
  const listDiff = createListDiff(address.listIndex, lastValue, newValue);
7137
- for (const listIndex of listDiff.newIndexes) {
7443
+ for (const listIndex of selectExpansionIndexes(context, sourcePath, lastValue, newValue, listDiff)) {
7138
7444
  const depAddress = createStateAddress(depPathInfo, listIndex);
7139
7445
  context.result.add(depAddress);
7140
7446
  nextEntries.push({ address: depAddress, depth: nextDepth });
@@ -7228,7 +7534,7 @@ function _walkDependency(context, startAddress, callback) {
7228
7534
  }
7229
7535
  }
7230
7536
  }
7231
- function walkDependency(stateName, stateElement, startAddress, staticDependency, dynamicDependency, listPathSet, stateProxy, searchType, callback) {
7537
+ function walkDependency(stateName, stateElement, startAddress, staticDependency, dynamicDependency, listPathSet, stateProxy, searchType, callback, options) {
7232
7538
  const context = {
7233
7539
  stateElement: stateElement,
7234
7540
  staticMap: staticDependency,
@@ -7238,6 +7544,7 @@ function walkDependency(stateName, stateElement, startAddress, staticDependency,
7238
7544
  visited: new Set(),
7239
7545
  stateProxy: stateProxy,
7240
7546
  searchType: searchType,
7547
+ listExpansion: options?.listExpansion ?? "full",
7241
7548
  };
7242
7549
  _walkDependency(context, startAddress, callback);
7243
7550
  return Array.from(context.result);
@@ -7303,7 +7610,10 @@ function _setByAddress(target, address, absAddress, value, receiver, handler) {
7303
7610
  dirtyCacheEntryByAbsoluteStateAddress(absDepAddress);
7304
7611
  // 更新対象として登録
7305
7612
  updater.enqueueAbsoluteAddress(absDepAddress);
7306
- });
7613
+ },
7614
+ // リスト置換時は追加行・位置変更行のみ展開する(未変更行の再訪を省く。
7615
+ // $postUpdate の手動リフレッシュは従来通り全行展開のまま)
7616
+ { listExpansion: "diff" });
7307
7617
  }
7308
7618
  }
7309
7619
  function _setByAddressWithSwap(target, address, absAddress, value, receiver, handler) {
@@ -8382,6 +8692,10 @@ class State extends HTMLElement {
8382
8692
  return getBindingsReady(rootNode);
8383
8693
  }
8384
8694
  __state;
8695
+ _hasUpdatedCallback = false;
8696
+ // 他行を読む getter が検出されたリストパス(diff-filter 展開の全行フォールバック対象)。
8697
+ // 依存マップ(static/dynamic)と同様に追加のみ・クリアしない(安全側に固定される)。
8698
+ _crossRowListPaths = new Set();
8385
8699
  _name = 'default';
8386
8700
  _initialized = false;
8387
8701
  _initializePromise;
@@ -8443,6 +8757,13 @@ class State extends HTMLElement {
8443
8757
  this._commandTokenNames = processCommandTokensDeclaration(value);
8444
8758
  this._eventTokenNames = processEventTokensDeclaration(value);
8445
8759
  this.__state = value;
8760
+ // $updatedCallback の有無を state セット時に確定しておく(in はプロトタイプ
8761
+ // チェーンも見る・getter を評価しない)。drain 側はこのフラグで更新アドレスの
8762
+ // 集計と writable createState をスキップできる。
8763
+ // 注: state セット後に生オブジェクトへ直接 $updatedCallback を後付けする
8764
+ // パターンは検知できない(bindProperty / _state 再セットは検知する)。
8765
+ // ライフサイクルフックは宣言時に定義するのが規約。
8766
+ this._hasUpdatedCallback = STATE_UPDATED_CALLBACK_NAME in value;
8446
8767
  // 再 set 時に二重 subscribe しないよう registry をクリアしてから $on を配線し直す。
8447
8768
  clearEventTokenRegistry(this);
8448
8769
  processOnDeclaration(this, value, this._eventTokenNames);
@@ -8865,8 +9186,20 @@ class State extends HTMLElement {
8865
9186
  this._version++;
8866
9187
  return this._version;
8867
9188
  }
9189
+ get hasUpdatedCallback() {
9190
+ return this._hasUpdatedCallback;
9191
+ }
9192
+ get crossRowListPaths() {
9193
+ return this._crossRowListPaths;
9194
+ }
9195
+ addCrossRowListPath(path) {
9196
+ this._crossRowListPaths.add(path);
9197
+ }
8868
9198
  bindProperty(prop, desc) {
8869
9199
  Object.defineProperty(this._state, prop, desc);
9200
+ if (prop === STATE_UPDATED_CALLBACK_NAME) {
9201
+ this._hasUpdatedCallback = true;
9202
+ }
8870
9203
  }
8871
9204
  setInitialState(state) {
8872
9205
  if (!this._initialized) {