@wcstack/state 1.17.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.17.0";
66
+ var version$1 = "1.19.0";
67
67
  var pkg = {
68
68
  version: version$1};
69
69
 
@@ -150,6 +150,9 @@ const STATE_COMMAND_TOKENS_NAME = "$commandTokens";
150
150
  const STATE_COMMAND_NAMESPACE_NAME = "$command";
151
151
  const STATE_EVENT_TOKENS_NAME = "$eventTokens";
152
152
  const STATE_ON_NAME = "$on";
153
+ const STATE_STREAMS_NAME = "$streams";
154
+ const STATE_STREAM_STATUS_NAMESPACE_NAME = "$streamStatus";
155
+ const STATE_STREAM_ERROR_NAMESPACE_NAME = "$streamError";
153
156
  const DCC_DEFINITION_ATTRIBUTE = "data-wc-definition";
154
157
 
155
158
  const _cache$4 = new Map();
@@ -1843,13 +1846,62 @@ const connectedCallbackSymbol = Symbol("$$connectedCallback");
1843
1846
  const disconnectedCallbackSymbol = Symbol("$$disconnectedCallback");
1844
1847
  const updatedCallbackSymbol = Symbol("$$updatedCallback");
1845
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
+
1846
1897
  // onclick: $command.<name> のように、DOM イベントから command token を直接 emit する形式かを判定する。
1847
1898
  // 右辺が $command 名前空間配下のパス($command.<token>)のときに true。
1848
1899
  function isCommandTokenPath(statePathName) {
1849
1900
  return statePathName.startsWith(STATE_COMMAND_NAMESPACE_NAME + ".");
1850
1901
  }
1851
1902
  const handlerByHandlerKey$3 = new Map();
1852
- const bindingSetByHandlerKey$3 = new Map();
1903
+ // binding を強参照しない台帳(handlerBindingRegistry.ts のリーク解説を参照)
1904
+ const bindingRegistry$3 = createHandlerBindingRegistry();
1853
1905
  function getHandlerKey$3(binding) {
1854
1906
  const modifierKey = binding.propModifiers.filter(m => m === 'prevent' || m === 'stop').sort().join(',');
1855
1907
  return `${binding.stateName}::${binding.statePathName}::${modifierKey}`;
@@ -1898,14 +1950,7 @@ function attachEventHandler(binding) {
1898
1950
  }
1899
1951
  const eventName = binding.propName.slice(2);
1900
1952
  binding.node.addEventListener(eventName, stateEventHandler);
1901
- let bindingSet = bindingSetByHandlerKey$3.get(key);
1902
- if (typeof bindingSet === "undefined") {
1903
- bindingSet = new Set([binding]);
1904
- bindingSetByHandlerKey$3.set(key, bindingSet);
1905
- }
1906
- else {
1907
- bindingSet.add(binding);
1908
- }
1953
+ bindingRegistry$3.add(key, binding);
1909
1954
  return true;
1910
1955
  }
1911
1956
 
@@ -1914,12 +1959,12 @@ function attachEventHandler(binding) {
1914
1959
  class EventToken extends Token {
1915
1960
  }
1916
1961
 
1917
- const registryByStateElement$1 = new WeakMap();
1962
+ const registryByStateElement$2 = new WeakMap();
1918
1963
  function getOrCreateEventToken(stateElement, name) {
1919
- let registry = registryByStateElement$1.get(stateElement);
1964
+ let registry = registryByStateElement$2.get(stateElement);
1920
1965
  if (typeof registry === "undefined") {
1921
1966
  registry = new Map();
1922
- registryByStateElement$1.set(stateElement, registry);
1967
+ registryByStateElement$2.set(stateElement, registry);
1923
1968
  }
1924
1969
  let token = registry.get(name);
1925
1970
  if (typeof token === "undefined") {
@@ -1929,7 +1974,7 @@ function getOrCreateEventToken(stateElement, name) {
1929
1974
  return token;
1930
1975
  }
1931
1976
  function clearEventTokenRegistry(stateElement) {
1932
- registryByStateElement$1.delete(stateElement);
1977
+ registryByStateElement$2.delete(stateElement);
1933
1978
  }
1934
1979
 
1935
1980
  /**
@@ -2075,7 +2120,8 @@ function isPossibleTwoWay(node, propName) {
2075
2120
  }
2076
2121
 
2077
2122
  const handlerByHandlerKey$2 = new Map();
2078
- const bindingSetByHandlerKey$2 = new Map();
2123
+ // binding を強参照しない台帳(handlerBindingRegistry.ts のリーク解説を参照)
2124
+ const bindingRegistry$2 = createHandlerBindingRegistry();
2079
2125
  const DEFAULT_GETTER = (e) => e.detail;
2080
2126
  function getHandlerKey$2(binding, eventName, hasGetter) {
2081
2127
  const filterKey = binding.inFilters.map(f => f.filterName + '(' + f.args.join(',') + ')').join('|');
@@ -2178,14 +2224,7 @@ function attachTwowayEventHandler(binding) {
2178
2224
  handlerByHandlerKey$2.set(key, twowayEventHandler);
2179
2225
  }
2180
2226
  binding.node.addEventListener(eventName, twowayEventHandler);
2181
- let bindingSet = bindingSetByHandlerKey$2.get(key);
2182
- if (typeof bindingSet === "undefined") {
2183
- bindingSet = new Set([binding]);
2184
- bindingSetByHandlerKey$2.set(key, bindingSet);
2185
- }
2186
- else {
2187
- bindingSet.add(binding);
2188
- }
2227
+ bindingRegistry$2.add(key, binding);
2189
2228
  }
2190
2229
  }
2191
2230
 
@@ -2890,6 +2929,103 @@ function calcDiffIndexes(oldList, newList, oldIndexes, newIndexes, indexByValue)
2890
2929
  };
2891
2930
  }
2892
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
+
2893
3029
  const bindingSetByAbsoluteStateAddress = new WeakMap();
2894
3030
  function getBindingSetByAbsoluteStateAddress(absoluteStateAddress) {
2895
3031
  let bindingSet = null;
@@ -2900,38 +3036,24 @@ function getBindingSetByAbsoluteStateAddress(absoluteStateAddress) {
2900
3036
  }
2901
3037
  return bindingSet;
2902
3038
  }
3039
+ /**
3040
+ * 参照専用の取得。get-or-create と違い、未登録アドレスに空 Set を
3041
+ * 生成・キャッシュしない(リスト置換の drain は大量のバインディング無し
3042
+ * アドレスを照会するため、生成すると空 Set が溜まり続ける)。
3043
+ */
3044
+ function peekBindingSetByAbsoluteStateAddress(absoluteStateAddress) {
3045
+ return bindingSetByAbsoluteStateAddress.get(absoluteStateAddress);
3046
+ }
2903
3047
  function addBindingByAbsoluteStateAddress(absoluteStateAddress, binding) {
2904
3048
  const bindingSet = getBindingSetByAbsoluteStateAddress(absoluteStateAddress);
2905
3049
  bindingSet.add(binding);
2906
3050
  }
2907
3051
  function removeBindingByAbsoluteStateAddress(absoluteStateAddress, binding) {
2908
- const bindingSet = getBindingSetByAbsoluteStateAddress(absoluteStateAddress);
2909
- bindingSet.delete(binding);
2910
- }
2911
-
2912
- const stateAddressByBindingInfo = new WeakMap();
2913
- function getStateAddressByBindingInfo(bindingInfo) {
2914
- let stateAddress = null;
2915
- stateAddress = stateAddressByBindingInfo.get(bindingInfo) || null;
2916
- if (stateAddress !== null) {
2917
- return stateAddress;
2918
- }
2919
- if (bindingInfo.statePathInfo.wildcardCount > 0) {
2920
- const listIndex = getListIndexByBindingInfo(bindingInfo);
2921
- if (listIndex === null) {
2922
- raiseError(`Cannot resolve state address for binding with wildcard statePathName "${bindingInfo.statePathName}" because list index is null.`);
2923
- }
2924
- stateAddress = createStateAddress(bindingInfo.statePathInfo, listIndex);
2925
- }
2926
- else {
2927
- stateAddress = createStateAddress(bindingInfo.statePathInfo, null);
3052
+ // get-or-create を通すと未登録アドレスに空 Set を生成してしまうため素の get で参照する
3053
+ const bindingSet = bindingSetByAbsoluteStateAddress.get(absoluteStateAddress);
3054
+ if (bindingSet !== undefined) {
3055
+ bindingSet.delete(binding);
2928
3056
  }
2929
- stateAddressByBindingInfo.set(bindingInfo, stateAddress);
2930
- return stateAddress;
2931
- }
2932
- // call for change loopContext
2933
- function clearStateAddressByBindingInfo(bindingInfo) {
2934
- stateAddressByBindingInfo.delete(bindingInfo);
2935
3057
  }
2936
3058
 
2937
3059
  const bindingsByContent = new WeakMap();
@@ -2980,8 +3102,10 @@ function deactivateContent(content) {
2980
3102
  for (const binding of bindings) {
2981
3103
  const absoluteStateAddress = getAbsoluteStateAddressByBinding(binding);
2982
3104
  removeBindingByAbsoluteStateAddress(absoluteStateAddress, binding);
2983
- clearAbsoluteStateAddressByBinding(binding);
2984
- clearStateAddressByBindingInfo(binding);
3105
+ // アドレスキャッシュ(absoluteStateAddressByBinding / stateAddressByBindingInfo)
3106
+ // のクリアはここでは行わない。deactivateContent の呼び出し元(for/if)は必ず
3107
+ // 直後に content.unmount() を呼び、unmount が同じ2台帳をネスト content も含めて
3108
+ // クリアする(createContent.ts)。ここで消すと全 binding で二重 delete になる。
2985
3109
  }
2986
3110
  unbindLoopContextToContent(content);
2987
3111
  }
@@ -3008,6 +3132,40 @@ function getContentSetByNode(node) {
3008
3132
  }
3009
3133
  return EMPTY_SET;
3010
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
+ }
3011
3169
 
3012
3170
  const recursiveBindingTypes = new Set(['if', 'elseif', 'else', 'for']);
3013
3171
  class Content {
@@ -3120,14 +3278,27 @@ function hydrateSetLastNode(node, lastNode) {
3120
3278
  function getPooledContents(bindingInfo) {
3121
3279
  return pooledContentsByNode.get(bindingInfo.node) || [];
3122
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;
3123
3288
  function setPooledContent(bindingInfo, content) {
3124
- const contents = pooledContentsByNode.get(bindingInfo.node);
3289
+ let contents = pooledContentsByNode.get(bindingInfo.node);
3125
3290
  if (typeof contents === 'undefined') {
3126
- pooledContentsByNode.set(bindingInfo.node, [content]);
3291
+ contents = [];
3292
+ pooledContentsByNode.set(bindingInfo.node, contents);
3127
3293
  }
3128
- else {
3294
+ if (contents.length < maxPooledContents) {
3129
3295
  contents.push(content);
3130
3296
  }
3297
+ else {
3298
+ // 上限超過: content を完全に手放す。contentSetByNode は createContent 時に
3299
+ // 追加されたきり解放経路が無いため、ここで外さないと GC できない。
3300
+ deleteContentByNode(bindingInfo.node, content);
3301
+ }
3131
3302
  }
3132
3303
  function isOnlyNodeInParentContent(firstNode, lastNode) {
3133
3304
  let prevCheckNode = firstNode.previousSibling;
@@ -3151,6 +3322,23 @@ function isOnlyNodeInParentContent(firstNode, lastNode) {
3151
3322
  }
3152
3323
  return onlyNode;
3153
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
+ }
3154
3342
  function getContent(node, listIndex) {
3155
3343
  let contentByListIndex = contentByListIndexByNode.get(node);
3156
3344
  if (typeof contentByListIndex === 'undefined') {
@@ -3210,6 +3398,11 @@ function applyChangeToFor(bindingInfo, context, newValue) {
3210
3398
  let lastNode = bindingInfo.node;
3211
3399
  const elementPathInfo = getPathInfo(listPathInfo.path + '.' + WILDCARD);
3212
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);
3213
3406
  let fragment = null;
3214
3407
  if (diff.newIndexes.length == diff.addIndexSet.size
3215
3408
  && diff.newIndexes.length > 0
@@ -3279,7 +3472,13 @@ function applyChangeToFor(bindingInfo, context, newValue) {
3279
3472
  if (content === null) {
3280
3473
  raiseError(`Content not found for ListIndex: ${index.index} at path "${listPathInfo.path}"`);
3281
3474
  }
3282
- 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) {
3283
3482
  content.mountAfter(lastNode);
3284
3483
  }
3285
3484
  }
@@ -3608,8 +3807,14 @@ function applyChangeToStyle(binding, _context, newValue) {
3608
3807
 
3609
3808
  const ssrWrappedNodes = new WeakSet();
3610
3809
  function applyChangeToText(binding, _context, newValue) {
3611
- if (binding.replaceNode.nodeValue !== newValue) {
3612
- 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;
3613
3818
  }
3614
3819
  // SSR モード時: テキストノードの前後にコメントを挿入して境界を明示
3615
3820
  if (inSsr() && !ssrWrappedNodes.has(binding.replaceNode)) {
@@ -3669,6 +3874,37 @@ function getValue(state, binding) {
3669
3874
  }
3670
3875
  }
3671
3876
 
3877
+ // applyChange が「未 define のカスタム要素」への適用を見送った binding の台帳。
3878
+ // define されるまでの間、同じ binding に対して applyChange は(state 更新の
3879
+ // たびに)何度も呼ばれうるため、whenDefined の多重登録をここで抑止する。
3880
+ // WeakSet なので binding の寿命に追従し、恒久 define されないタグでもリークしない。
3881
+ const scheduledBindings = new WeakSet();
3882
+ /**
3883
+ * 未 define のカスタム要素に対する適用を customElements.whenDefined 後に再実行
3884
+ * する。two-way / event-token の attach、spread の deferred 展開はいずれも
3885
+ * whenDefined で再試行するのに対し、値の適用だけが片道 skip だった非対称の解消
3886
+ * (docs/state-binding-init-races.md §2)。
3887
+ *
3888
+ * 再適用は applyChangeFromBindings を通すため、define 時点の最新 state 値で
3889
+ * 適用される(skip 時点の値を保持しない)。define を待つ間に DOM から外れた
3890
+ * binding には適用しない(deferred spread と同じ規約)。
3891
+ */
3892
+ function scheduleDeferredApply(binding, tagName) {
3893
+ if (scheduledBindings.has(binding)) {
3894
+ return;
3895
+ }
3896
+ scheduledBindings.add(binding);
3897
+ customElements.whenDefined(tagName).then(() => {
3898
+ scheduledBindings.delete(binding);
3899
+ if (!binding.replaceNode.isConnected) {
3900
+ return; // define を待つ間にノードが削除された
3901
+ }
3902
+ applyChangeFromBindings([binding]);
3903
+ }).catch((error) => {
3904
+ console.error(`[@wcstack/state] deferred apply failed for <${tagName}>.`, error);
3905
+ });
3906
+ }
3907
+
3672
3908
  const applyChangeByFirstSegment = {
3673
3909
  "class": applyChangeToClass,
3674
3910
  "attr": applyChangeToAttribute,
@@ -3749,15 +3985,20 @@ function applyChange(binding, context) {
3749
3985
  return;
3750
3986
  }
3751
3987
  context.appliedBindingSet.add(binding);
3752
- const absAddress = getAbsoluteStateAddressByBinding(binding);
3753
- if (context.updatedAbsAddressSetByStateElement.has(context.stateElement)) {
3754
- const addressSet = context.updatedAbsAddressSetByStateElement.get(context.stateElement);
3755
- addressSet.add(absAddress);
3756
- }
3757
- else {
3758
- context.updatedAbsAddressSetByStateElement.set(context.stateElement, new Set([
3759
- absAddress
3760
- ]));
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
+ }
3761
4002
  }
3762
4003
  if (binding.bindingType === "event") {
3763
4004
  return;
@@ -3765,10 +4006,22 @@ function applyChange(binding, context) {
3765
4006
  const customTag = getCustomElement(binding.replaceNode);
3766
4007
  if (customTag) {
3767
4008
  if (customElements.get(customTag) === undefined) {
3768
- // cutomElement側の初期化を期待
4009
+ // 未 define のカスタム要素へは今は適用できない(accessor 未確立の要素に
4010
+ // 素の own property を書くと upgrade 後に class accessor を隠してしまう)。
4011
+ // whenDefined 後に最新 state 値で再適用する(two-way attach / deferred
4012
+ // spread と対称。docs/state-binding-init-races.md §2)。
4013
+ scheduleDeferredApply(binding, customTag);
3769
4014
  return;
3770
4015
  }
3771
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
+ }
3772
4025
  let rootNode = binding.replaceNode.getRootNode();
3773
4026
  if (rootNode instanceof DocumentFragment && !(rootNode instanceof ShadowRoot)) {
3774
4027
  rootNode = getRootNodeByFragment(rootNode);
@@ -3849,6 +4102,9 @@ function applyChangeFromBindings(bindings) {
3849
4102
  newListValueByAbsAddress: newListValueByAbsAddress,
3850
4103
  updatedAbsAddressSetByStateElement: updatedAbsAddressSetByStateElement,
3851
4104
  deferredSelectBindings: deferredSelectBindings,
4105
+ // グループ内の binding は下の do/while が「解決済みルート === rootNode」を
4106
+ // 検証してから applyChange に渡す(applyChange 側の getRootNode 省略の根拠)
4107
+ sameRootVerified: true,
3852
4108
  };
3853
4109
  do {
3854
4110
  applyChange(binding, context);
@@ -3879,7 +4135,8 @@ function applyChangeFromBindings(bindings) {
3879
4135
  }
3880
4136
 
3881
4137
  const handlerByHandlerKey$1 = new Map();
3882
- const bindingSetByHandlerKey$1 = new Map();
4138
+ // binding を強参照しない台帳(handlerBindingRegistry.ts のリーク解説を参照)
4139
+ const bindingRegistry$1 = createHandlerBindingRegistry();
3883
4140
  function getHandlerKey$1(binding, eventName) {
3884
4141
  const filterKey = binding.inFilters.map(f => f.filterName + '(' + f.args.join(',') + ')').join('|');
3885
4142
  return `${binding.stateName}::${binding.statePathName}::${eventName}::${filterKey}`;
@@ -3933,21 +4190,15 @@ function attachRadioEventHandler(binding) {
3933
4190
  handlerByHandlerKey$1.set(key, radioEventHandler);
3934
4191
  }
3935
4192
  binding.node.addEventListener(eventName, radioEventHandler);
3936
- let bindingSet = bindingSetByHandlerKey$1.get(key);
3937
- if (typeof bindingSet === "undefined") {
3938
- bindingSet = new Set([binding]);
3939
- bindingSetByHandlerKey$1.set(key, bindingSet);
3940
- }
3941
- else {
3942
- bindingSet.add(binding);
3943
- }
4193
+ bindingRegistry$1.add(key, binding);
3944
4194
  return true;
3945
4195
  }
3946
4196
  return false;
3947
4197
  }
3948
4198
 
3949
4199
  const handlerByHandlerKey = new Map();
3950
- const bindingSetByHandlerKey = new Map();
4200
+ // binding を強参照しない台帳(handlerBindingRegistry.ts のリーク解説を参照)
4201
+ const bindingRegistry = createHandlerBindingRegistry();
3951
4202
  function getHandlerKey(binding, eventName) {
3952
4203
  const filterKey = binding.inFilters.map(f => f.filterName + '(' + f.args.join(',') + ')').join('|');
3953
4204
  return `${binding.stateName}::${binding.statePathName}::${eventName}::${filterKey}`;
@@ -4020,14 +4271,7 @@ function attachCheckboxEventHandler(binding) {
4020
4271
  handlerByHandlerKey.set(key, checkboxEventHandler);
4021
4272
  }
4022
4273
  binding.node.addEventListener(eventName, checkboxEventHandler);
4023
- let bindingSet = bindingSetByHandlerKey.get(key);
4024
- if (typeof bindingSet === "undefined") {
4025
- bindingSet = new Set([binding]);
4026
- bindingSetByHandlerKey.set(key, bindingSet);
4027
- }
4028
- else {
4029
- bindingSet.add(binding);
4030
- }
4274
+ bindingRegistry.add(key, binding);
4031
4275
  return true;
4032
4276
  }
4033
4277
  return false;
@@ -5486,12 +5730,12 @@ function processCommandTokensDeclaration(state) {
5486
5730
  return names;
5487
5731
  }
5488
5732
 
5489
- const registryByStateElement = new WeakMap();
5733
+ const registryByStateElement$1 = new WeakMap();
5490
5734
  function getOrCreateCommandToken(stateElement, name) {
5491
- let registry = registryByStateElement.get(stateElement);
5735
+ let registry = registryByStateElement$1.get(stateElement);
5492
5736
  if (typeof registry === "undefined") {
5493
5737
  registry = new Map();
5494
- registryByStateElement.set(stateElement, registry);
5738
+ registryByStateElement$1.set(stateElement, registry);
5495
5739
  }
5496
5740
  let token = registry.get(name);
5497
5741
  if (typeof token === "undefined") {
@@ -5501,7 +5745,7 @@ function getOrCreateCommandToken(stateElement, name) {
5501
5745
  return token;
5502
5746
  }
5503
5747
  function clearCommandTokenRegistry(stateElement) {
5504
- registryByStateElement.delete(stateElement);
5748
+ registryByStateElement$1.delete(stateElement);
5505
5749
  }
5506
5750
 
5507
5751
  /**
@@ -5623,95 +5867,1005 @@ function processOnDeclaration(stateElement, state, eventTokenNames) {
5623
5867
  }
5624
5868
  }
5625
5869
 
5626
- function getterFn(name) {
5627
- return function () {
5628
- const stateEl = this.stateElement;
5629
- if (!stateEl)
5630
- return undefined;
5631
- let value;
5632
- try {
5633
- stateEl.createState("readonly", (state) => {
5634
- value = state[name];
5635
- });
5636
- }
5637
- catch (e) {
5638
- console.warn(`[@wcstack/state] DCC getter "${name}" failed:`, e);
5639
- return undefined;
5640
- }
5641
- return value;
5642
- };
5643
- }
5644
- function setterFn(name) {
5645
- return function (value) {
5646
- const stateEl = this.stateElement;
5647
- if (!stateEl)
5648
- return;
5649
- stateEl.initializePromise.then(() => {
5650
- stateEl.createState("writable", (state) => {
5651
- state[name] = value;
5652
- });
5653
- });
5654
- };
5870
+ /**
5871
+ * stream/lastNotified.ts
5872
+ *
5873
+ * 「最後に通知した観測値」台帳 — DOM binding / $updatedCallback(観測層)が
5874
+ * 最後に見た status・error(docs/state-streams-design.md §4-3)。
5875
+ *
5876
+ * 通知の same-value 判定を entry フィールドとの比較で行うと、再 set
5877
+ * (clearStreamRegistry → 新 entry 生成)を跨いだ陳腐化を検出できない
5878
+ * (error 表示中に再 set すると新 entry は error=null で生まれるため
5879
+ * null → null と誤判定して $postUpdate が落ち、DOM に旧 error が残る)。
5880
+ * そのため通知 dedup は entry の寿命ではなく stateElement の寿命で持つ
5881
+ * (ただし再 set で新宣言から消えた名前のエントリは pruneLastNotified で削除する —
5882
+ * 同名にしか dedup は要らず、放置すると台帳が単調増加するため)。
5883
+ * 未通知(初回)の基準値は宣言直後の観測初期値と同じ { idle, null }。
5884
+ *
5885
+ * さらに abortAllStreams(§5-1)は registry entry を通知なしで idle / null に
5886
+ * 直接ミューテーションするため、観測層が「台帳の値」と「idle / null」の
5887
+ * どちらを見たか確定できなくなる(binding / computed の fresh 読みは通知が
5888
+ * なくても他パスの drain で走る)。その乖離フィールドは invalidateLastNotified
5889
+ * UNCERTAIN に無効化し、次回 updateStreamStatus の同値判定が必ず
5890
+ * 「変化あり」になるようにする(再接続ウィンドウ内の idle 描画が恒久陳腐化
5891
+ * しないための不変条件、§4-3)。
5892
+ */
5893
+ /**
5894
+ * 無通知ミューテーション後の「観測値が確定できない」印。
5895
+ * どの実値とも一致しないため、次回の通知 dedup(`!==` / `Object.is`)を強制的に解除する。
5896
+ */
5897
+ const UNCERTAIN = Symbol("wcs-stream-last-notified-uncertain");
5898
+ const lastNotifiedByStateElement = new WeakMap();
5899
+ /**
5900
+ * 最後に通知した観測値を返す。未通知なら基準値 { idle, null }。
5901
+ */
5902
+ function getLastNotified(stateElement, name) {
5903
+ return (lastNotifiedByStateElement.get(stateElement)?.get(name) ?? { status: "idle", error: null });
5655
5904
  }
5656
- function callFn(name, isAsync) {
5657
- if (isAsync) {
5658
- return function (...args) {
5659
- const stateEl = this.stateElement;
5660
- if (!stateEl)
5661
- return undefined;
5662
- return stateEl.initializePromise.then(() => {
5663
- let result;
5664
- return stateEl.createStateAsync("writable", async (state) => {
5665
- result = await state[name](...args);
5666
- }).then(() => result);
5667
- });
5668
- };
5905
+ /**
5906
+ * 通知した観測値を記録する(updateStreamStatus が $postUpdate 発行と同時に呼ぶ)。
5907
+ */
5908
+ function setLastNotified(stateElement, name, status, error) {
5909
+ let lastMap = lastNotifiedByStateElement.get(stateElement);
5910
+ if (typeof lastMap === "undefined") {
5911
+ lastMap = new Map();
5912
+ lastNotifiedByStateElement.set(stateElement, lastMap);
5669
5913
  }
5670
- return function (...args) {
5671
- const stateEl = this.stateElement;
5672
- if (!stateEl)
5673
- return undefined;
5674
- return stateEl.initializePromise.then(() => {
5675
- let result;
5676
- stateEl.createState("writable", (state) => {
5677
- result = state[name](...args);
5678
- });
5679
- return result;
5680
- });
5681
- };
5682
- }
5683
- function isInternalProperty(name) {
5684
- return name.startsWith("$");
5685
- }
5686
-
5687
- function createWcBindable(tagName, bindables) {
5688
- const properties = bindables.map((propName) => ({
5689
- name: propName,
5690
- event: `${tagName}:${propName}-changed`,
5691
- }));
5692
- return {
5693
- protocol: "wc-bindable",
5694
- version: 1,
5695
- properties,
5696
- };
5914
+ lastMap.set(name, { status, error });
5697
5915
  }
5698
- function createBindableEventMap(tagName, bindables) {
5699
- const map = {};
5700
- for (const propName of bindables) {
5701
- map[propName] = `${tagName}:${propName}-changed`;
5916
+ /**
5917
+ * set(clearStreamRegistry → processStreamsDeclaration)後に呼び、新宣言に
5918
+ * 存在しない名前の台帳エントリを削除する。台帳は stateElement の寿命で生存するが
5919
+ * (§4-3 の再 set・再接続跨ぎ dedup)、それが必要なのは同名エントリのみで、
5920
+ * 旧宣言にしか無い名前は以後どの通知経路(updateStreamStatus)からも参照されない。
5921
+ * prune しないと、再 set のたびに異なる stream 名を使うステートで台帳が
5922
+ * stateElement の寿命の間単調増加する。
5923
+ * 既知の許容: prune 後に同名を再宣言した場合、dedup は基準値 { idle, null } から
5924
+ * やり直しになる(宣言削除時の binding 陳腐化が §4-4 の既知エッジである以上、
5925
+ * 再宣言は新規宣言と同じ扱いでよい)。
5926
+ */
5927
+ function pruneLastNotified(stateElement, liveNames) {
5928
+ const lastMap = lastNotifiedByStateElement.get(stateElement);
5929
+ if (typeof lastMap === "undefined") {
5930
+ return;
5931
+ }
5932
+ for (const name of lastMap.keys()) {
5933
+ if (!liveNames.has(name)) {
5934
+ lastMap.delete(name);
5935
+ }
5702
5936
  }
5703
- return map;
5704
5937
  }
5705
-
5706
- function defineDCC(hostElement, shadowRoot, state) {
5707
- const tagName = hostElement.tagName.toLowerCase();
5708
- // バリデーション
5709
- if (!tagName.includes("-")) {
5710
- raiseError(`DCC: "${tagName}" is not a valid custom element name (must contain a hyphen).`);
5938
+ /**
5939
+ * 無通知ミューテーション(abortAllStreams idle / null 直接書き換え)の直後に呼び、
5940
+ * 台帳のうちミューテーション後の値と一致しないフィールドを UNCERTAIN に無効化する。
5941
+ * 一致しているフィールド(観測層がどちらを見ても同じ値)は dedup を維持する
5942
+ * (例: error が null のままなら再接続時に $streamError.<name> の余計な通知は出ない)。
5943
+ */
5944
+ function invalidateLastNotified(stateElement, name) {
5945
+ const lastMap = lastNotifiedByStateElement.get(stateElement);
5946
+ if (typeof lastMap === "undefined") {
5947
+ return;
5711
5948
  }
5712
- if (customElements.get(tagName)) {
5713
- // 既に登録済みならスキップ(重複定義の検知のため警告は出す)
5714
- console.warn(`[@wcstack/state] DCC: "${tagName}" is already registered. Skipping redefinition.`);
5949
+ const last = lastMap.get(name);
5950
+ if (typeof last === "undefined") {
5951
+ // 未通知: 基準値 { idle, null } はミューテーション後の値と一致するため乖離しない
5952
+ return;
5953
+ }
5954
+ lastMap.set(name, {
5955
+ status: last.status === "idle" ? last.status : UNCERTAIN,
5956
+ error: Object.is(last.error, null) ? null : UNCERTAIN,
5957
+ });
5958
+ }
5959
+
5960
+ /**
5961
+ * stream/activeStateElements.ts
5962
+ *
5963
+ * 起動中(startStreams 済み・未切断)の stateElement の列挙用 Set
5964
+ * (docs/state-streams-design.md §3-2)。
5965
+ *
5966
+ * streamRegistry の WeakMap は列挙不能のため、updater の drain リスナーが
5967
+ * 「どの stateElement の entry と batch を交差させるか」を知るには
5968
+ * 列挙可能な strong Set が別途必要になる。lastNotified.ts と同じ
5969
+ * 「import 循環回避の小モジュール」パターン
5970
+ * (streamRegistry → activeStateElements ← streamRuntime の一方向依存に保つ)。
5971
+ *
5972
+ * リーク防止の不変条件(strong Set が切断済み要素の GC を妨げないための連動):
5973
+ * - add は startStreams(streamRuntime.ts)だけが行う
5974
+ * (eager 起動=connect 時、および接続中の `_state` 再 set 時の再起動)。
5975
+ * - delete は abortAllStreams / clearStreamRegistry(streamRegistry.ts)が行う。
5976
+ * disconnect(disconnectedCallback → abortAllStreams)と `_state` 再 set
5977
+ * (clearStreamRegistry → processStreamsDeclaration → 接続中なら startStreams で
5978
+ * 再 add)の両経路が必ずここを通るため、「Set に居る = 接続中かつ起動済み」が
5979
+ * 常に保たれ、切断済み stateElement への強参照は残らない。
5980
+ * 設計書 §3-2 の「未接続(disconnect 済み)の stateElement の entry は restart
5981
+ * しない」はこの不変条件で担保される。
5982
+ */
5983
+ const activeStateElements = new Set();
5984
+ /**
5985
+ * 起動中 stateElement として登録する(startStreams 専用。不変条件はモジュールヘッダ参照)。
5986
+ */
5987
+ function addActiveStateElement(stateElement) {
5988
+ activeStateElements.add(stateElement);
5989
+ }
5990
+ /**
5991
+ * 起動中 stateElement から外す(abortAllStreams / clearStreamRegistry 専用)。
5992
+ */
5993
+ function deleteActiveStateElement(stateElement) {
5994
+ activeStateElements.delete(stateElement);
5995
+ }
5996
+ /**
5997
+ * 起動中 stateElement を列挙する(drain リスナーの交差判定用)。
5998
+ */
5999
+ function getActiveStateElements() {
6000
+ return activeStateElements;
6001
+ }
6002
+
6003
+ /**
6004
+ * stream/streamRegistry.ts
6005
+ *
6006
+ * `$streams` の registry(docs/state-streams-design.md §2-1 / §5)。
6007
+ * eventTokenRegistry と対称の WeakMap registry。
6008
+ *
6009
+ * - status / error の正本は registry entry(state オブジェクト上に実プロパティは持たない)。
6010
+ * - disconnect 時は abortAllStreams(abort のみ・registry 保持)、
6011
+ * `_state` 再 set 時のみ clearStreamRegistry(abort + 全削除)。
6012
+ */
6013
+ const registryByStateElement = new WeakMap();
6014
+ /**
6015
+ * stream entry 群を置換登録する(`_state` セッターからの再構築で丸ごと差し替える)。
6016
+ */
6017
+ function setStreamEntries(stateElement, entries) {
6018
+ registryByStateElement.set(stateElement, entries);
6019
+ }
6020
+ /**
6021
+ * 登録済みの stream entry 群を返す。未登録なら空 Map を返す(registry への登録はしない)。
6022
+ */
6023
+ function getStreamEntries(stateElement) {
6024
+ return registryByStateElement.get(stateElement) ?? new Map();
6025
+ }
6026
+ /**
6027
+ * 全 stream を abort して idle に戻す(設計書 §5-1)。registry は保持する。
6028
+ *
6029
+ * disconnectedCallback(切断時)に呼ばれるため、status / error の反映は
6030
+ * proxy / $postUpdate を使わず entry への直接ミューテーションで行う
6031
+ * (切断済みで binding 更新は不要かつ rootNode が無い)。
6032
+ *
6033
+ * 無通知ミューテーションは「最後に通知した観測値」台帳(stream/lastNotified.ts)
6034
+ * と registry を乖離させるため、同時に台帳側を invalidate する。これを怠ると
6035
+ * 再接続ウィンドウ内の fresh 読み(他パスの drain での getter 再計算など)が
6036
+ * 描画した idle に対し、restart の updateStreamStatus("active") が切断前の
6037
+ * 通知値と同値判定されて skip され、DOM が恒久的に陳腐化する(設計書 §4-3)。
6038
+ */
6039
+ function abortAllStreams(stateElement) {
6040
+ // 依存駆動 restart の対象から外す(切断済み stateElement は restart しない、
6041
+ // 設計書 §3-2。add 側は startStreams — stream/activeStateElements.ts の
6042
+ // リーク防止不変条件を参照)。registry の有無に関わらず必ず外す。
6043
+ deleteActiveStateElement(stateElement);
6044
+ const entries = registryByStateElement.get(stateElement);
6045
+ if (typeof entries === "undefined") {
6046
+ return;
6047
+ }
6048
+ for (const entry of entries.values()) {
6049
+ entry.controller?.abort();
6050
+ entry.controller = null;
6051
+ entry.status = "idle";
6052
+ entry.error = null;
6053
+ invalidateLastNotified(stateElement, entry.name);
6054
+ }
6055
+ }
6056
+ /**
6057
+ * 全 stream を abort したうえで registry から削除する(`_state` 再 set 時の再配線用、設計書 §5-2)。
6058
+ */
6059
+ function clearStreamRegistry(stateElement) {
6060
+ abortAllStreams(stateElement);
6061
+ // abortAllStreams が既に delete 済みだが、「clear = 全削除でも必ず restart 対象から
6062
+ // 外れる」不変条件を将来の abortAllStreams の変更から独立に保証するため明示的に呼ぶ。
6063
+ deleteActiveStateElement(stateElement);
6064
+ registryByStateElement.delete(stateElement);
6065
+ }
6066
+
6067
+ /**
6068
+ * stream/processStreamsDeclaration.ts
6069
+ *
6070
+ * `$streams: { <name>: { args?, source, fold?, initial? } }` 宣言マップを解析し、
6071
+ * IStreamEntry を構築して streamRegistry に一括登録する
6072
+ * (docs/state-streams-design.md §1-1 / §1-2 / §1-3)。
6073
+ *
6074
+ * - バリデーション(§1-2): 違反は raiseError。
6075
+ * - 名前はフラットなプロパティ名のみ(空文字 / `.`(DELIMITER)/ `*`(WILDCARD)/ 先頭 `$` を禁止)。
6076
+ * - Object.prototype の継承名(`__proto__` / `constructor` / `toString` 等)を禁止
6077
+ * (own key でなくても `in` 判定が真になり、実体化 skip + 起動時 Reflect.set の
6078
+ * 継承 setter 化 — `__proto__` は prototype 差し替え — を引き起こすため)。
6079
+ * - getter / setter として宣言済みのパスとの衝突を禁止(getterPaths / setterPaths を検査)。
6080
+ * - `source` は関数必須。`fold` は(あれば)関数。`fold` があるのに `initial` が無ければエラー
6081
+ * (reduce は initial 必須。`initial` の有無は in 演算子で判定)。`args` は(あれば)関数。
6082
+ * - fold 省略時は latest(`(_acc, chunk) => chunk`)を注入する(§0 決定レコード)。
6083
+ * - 値プロパティ実体化(§1-3): `state[name]` が未定義なら `initial`
6084
+ * (fold 無しなら undefined)でデータプロパティとして初期化する。
6085
+ * ユーザーが同名プロパティを先に宣言していた場合は上書きしない
6086
+ * (起動時の initial リセットは streamRuntime 側の責務)。
6087
+ * - 通知 dedup 台帳の prune(§4-3): 新宣言に存在しない名前の lastNotified エントリを
6088
+ * 削除する(台帳は stateElement 寿命 — 再 set 跨ぎ dedup が必要なのは同名のみ)。
6089
+ *
6090
+ * 呼び出しは stateElement.getterPaths / setterPaths の確定後であること
6091
+ * (State の `_state` セッターが getStateInfo の反映より後に呼ぶことで保証する)。
6092
+ */
6093
+ /** fold 省略時に注入される既定 fold(latest = 最新チャンクで置換) */
6094
+ const latestFold = (_acc, chunk) => chunk;
6095
+ /** `$streams` 無し宣言の prune 用(旧宣言の全名前が残骸になる) */
6096
+ const NO_STREAM_NAMES = new Set();
6097
+ function processStreamsDeclaration(stateElement, state) {
6098
+ const declared = state[STATE_STREAMS_NAME];
6099
+ if (typeof declared === "undefined") {
6100
+ // $streams 無しの再 set でも旧宣言の名前は通知 dedup 台帳の残骸になるため prune する
6101
+ pruneLastNotified(stateElement, NO_STREAM_NAMES);
6102
+ return;
6103
+ }
6104
+ if (typeof declared !== "object" || declared === null) {
6105
+ raiseError(`${STATE_STREAMS_NAME} must be an object mapping stream names to stream definitions.`);
6106
+ }
6107
+ const entries = new Map();
6108
+ for (const [name, def] of Object.entries(declared)) {
6109
+ if (name.length === 0) {
6110
+ raiseError(`${STATE_STREAMS_NAME} entry name must be a non-empty string.`);
6111
+ }
6112
+ if (name.includes(DELIMITER)) {
6113
+ raiseError(`${STATE_STREAMS_NAME} entry "${name}" must be a flat property name ("${DELIMITER}" is not allowed).`);
6114
+ }
6115
+ if (name.includes(WILDCARD)) {
6116
+ raiseError(`${STATE_STREAMS_NAME} entry "${name}" must be a flat property name ("${WILDCARD}" is not allowed).`);
6117
+ }
6118
+ if (name.startsWith("$")) {
6119
+ raiseError(`${STATE_STREAMS_NAME} entry "${name}" must not start with "$" (reserved namespace).`);
6120
+ }
6121
+ // Object.prototype の継承名(__proto__ / constructor / toString 等)は一律拒否する。
6122
+ // own key でないのに `name in state` が真になるため実体化(§1-3)が skip され、
6123
+ // 起動時の initial リセット(Reflect.set)が継承 setter に化ける
6124
+ // (特に __proto__ は state の prototype を差し替える)ため、名前検査の防衛線で落とす(§1-2)。
6125
+ if (name in Object.prototype) {
6126
+ raiseError(`${STATE_STREAMS_NAME} entry "${name}" must not be a property name inherited from Object.prototype (e.g. "__proto__", "constructor").`);
6127
+ }
6128
+ if (stateElement.getterPaths.has(name)) {
6129
+ raiseError(`${STATE_STREAMS_NAME} entry "${name}" conflicts with a getter declared on the state.`);
6130
+ }
6131
+ if (stateElement.setterPaths.has(name)) {
6132
+ raiseError(`${STATE_STREAMS_NAME} entry "${name}" conflicts with a setter declared on the state.`);
6133
+ }
6134
+ if (typeof def !== "object" || def === null) {
6135
+ raiseError(`${STATE_STREAMS_NAME} entry "${name}" must be an object ({ args?, source, fold?, initial? }).`);
6136
+ }
6137
+ const definition = def;
6138
+ if (typeof definition.source !== "function") {
6139
+ raiseError(`${STATE_STREAMS_NAME} entry "${name}" source must be a function.`);
6140
+ }
6141
+ const hasFold = typeof definition.fold !== "undefined";
6142
+ if (hasFold && typeof definition.fold !== "function") {
6143
+ raiseError(`${STATE_STREAMS_NAME} entry "${name}" fold must be a function.`);
6144
+ }
6145
+ if (hasFold && !("initial" in definition)) {
6146
+ raiseError(`${STATE_STREAMS_NAME} entry "${name}" requires "initial" when fold is specified (reduce needs a seed value).`);
6147
+ }
6148
+ const hasArgs = typeof definition.args !== "undefined";
6149
+ if (hasArgs && typeof definition.args !== "function") {
6150
+ raiseError(`${STATE_STREAMS_NAME} entry "${name}" args must be a function.`);
6151
+ }
6152
+ const entry = {
6153
+ name,
6154
+ definition: {
6155
+ args: definition.args ?? null,
6156
+ source: definition.source,
6157
+ fold: definition.fold ?? latestFold,
6158
+ initial: definition.initial,
6159
+ },
6160
+ status: "idle",
6161
+ error: null,
6162
+ controller: null,
6163
+ depAddresses: new Set(),
6164
+ };
6165
+ // 値プロパティ実体化(§1-3): ユーザーが同名プロパティを先に宣言していたら上書きしない
6166
+ if (!(name in state)) {
6167
+ state[name] = entry.definition.initial;
6168
+ }
6169
+ entries.set(name, entry);
6170
+ }
6171
+ setStreamEntries(stateElement, entries);
6172
+ // 新宣言に存在しない名前の通知 dedup 台帳エントリを prune する
6173
+ // (同名は保持 = §4-3 の再 set 跨ぎ dedup 契約を維持。stream/lastNotified.ts 参照)
6174
+ pruneLastNotified(stateElement, new Set(entries.keys()));
6175
+ }
6176
+
6177
+ /**
6178
+ * stream/streamNamespace.ts
6179
+ *
6180
+ * `$streamStatus` / `$streamError` の read-only namespace proxy
6181
+ * (docs/state-streams-design.md §4-1 / §4-2)。commandNamespace と対称。
6182
+ *
6183
+ * - state element 単位で memo 化し、同一 stateElement なら同じ proxy が返る。
6184
+ * - 宣言された stream 名(`$streams` に列挙されたもの)のみ registry entry の
6185
+ * status / error を返す。宣言外の名前・Symbol キーは undefined
6186
+ * (`then` / `constructor` 等を内部機構が触っても throw しない寛容規約、
6187
+ * $command と同じ)。
6188
+ * - 値は memo しない: proxy は getStreamEntries を毎回読む thin gateway
6189
+ * (status / error は runtime が随時書き換えるため。registry entry が正本、§2-1)。
6190
+ * - set / deleteProperty は raiseError。setByAddress の親走査が namespace proxy に
6191
+ * 到達したときの Reflect.set もここで落ちる(書き込み防御 S11 の終端)。
6192
+ */
6193
+ const statusNamespaceByStateElement = new WeakMap();
6194
+ const errorNamespaceByStateElement = new WeakMap();
6195
+ function createStreamNamespaceProxy(stateElement, namespaceName, pick) {
6196
+ return new Proxy(Object.create(null), {
6197
+ get(_target, prop) {
6198
+ if (typeof prop !== "string") {
6199
+ return undefined;
6200
+ }
6201
+ const entry = getStreamEntries(stateElement).get(prop);
6202
+ if (typeof entry === "undefined") {
6203
+ return undefined;
6204
+ }
6205
+ return pick(entry);
6206
+ },
6207
+ has(_target, prop) {
6208
+ return typeof prop === "string" && getStreamEntries(stateElement).has(prop);
6209
+ },
6210
+ ownKeys() {
6211
+ return Array.from(getStreamEntries(stateElement).keys());
6212
+ },
6213
+ getOwnPropertyDescriptor(_target, prop) {
6214
+ if (typeof prop !== "string") {
6215
+ return undefined;
6216
+ }
6217
+ const entry = getStreamEntries(stateElement).get(prop);
6218
+ if (typeof entry === "undefined") {
6219
+ return undefined;
6220
+ }
6221
+ return {
6222
+ configurable: true,
6223
+ enumerable: true,
6224
+ value: pick(entry),
6225
+ };
6226
+ },
6227
+ set() {
6228
+ raiseError(`${namespaceName} namespace is read-only; assigning to it is not allowed.`);
6229
+ },
6230
+ deleteProperty() {
6231
+ raiseError(`${namespaceName} namespace is read-only; deleting from it is not allowed.`);
6232
+ },
6233
+ });
6234
+ }
6235
+ function getStreamStatusNamespace(stateElement) {
6236
+ const cached = statusNamespaceByStateElement.get(stateElement);
6237
+ if (typeof cached !== "undefined") {
6238
+ return cached;
6239
+ }
6240
+ const proxy = createStreamNamespaceProxy(stateElement, STATE_STREAM_STATUS_NAMESPACE_NAME, (entry) => entry.status);
6241
+ statusNamespaceByStateElement.set(stateElement, proxy);
6242
+ return proxy;
6243
+ }
6244
+ function getStreamErrorNamespace(stateElement) {
6245
+ const cached = errorNamespaceByStateElement.get(stateElement);
6246
+ if (typeof cached !== "undefined") {
6247
+ return cached;
6248
+ }
6249
+ const proxy = createStreamNamespaceProxy(stateElement, STATE_STREAM_ERROR_NAMESPACE_NAME, (entry) => entry.error);
6250
+ errorNamespaceByStateElement.set(stateElement, proxy);
6251
+ return proxy;
6252
+ }
6253
+ /**
6254
+ * 両 namespace proxy の memo を破棄する(clearCommandNamespace と対称)。
6255
+ * disconnectedCallback と `_state` 再 set 時に呼ばれる。
6256
+ */
6257
+ function clearStreamNamespace(stateElement) {
6258
+ statusNamespaceByStateElement.delete(stateElement);
6259
+ errorNamespaceByStateElement.delete(stateElement);
6260
+ }
6261
+
6262
+ const updateBatchListeners = new Set();
6263
+ /**
6264
+ * drain 終了リスナーを登録する。
6265
+ */
6266
+ function registerUpdateBatchListener(listener) {
6267
+ updateBatchListeners.add(listener);
6268
+ }
6269
+ /**
6270
+ * 全リスナーに drain のバッチを通知する。
6271
+ * リスナーの throw は握りつぶさない(内部バグの隠蔽防止)。
6272
+ * stream 側リスナーが entry ごとに自前で try/catch する契約(設計書 §3-2)。
6273
+ */
6274
+ function notifyUpdateBatchListeners(batch) {
6275
+ for (const listener of updateBatchListeners) {
6276
+ listener(batch);
6277
+ }
6278
+ }
6279
+ class Updater {
6280
+ _queueAbsoluteAddresses = [];
6281
+ constructor() {
6282
+ }
6283
+ enqueueAbsoluteAddress(absoluteAddress) {
6284
+ const requireStartProcess = this._queueAbsoluteAddresses.length === 0;
6285
+ this._queueAbsoluteAddresses.push(absoluteAddress);
6286
+ if (requireStartProcess) {
6287
+ queueMicrotask(() => {
6288
+ const absoluteAddresses = this._queueAbsoluteAddresses;
6289
+ this._queueAbsoluteAddresses = [];
6290
+ this._applyChange(absoluteAddresses);
6291
+ });
6292
+ }
6293
+ }
6294
+ // テスト用に公開
6295
+ testApplyChange(absoluteAddresses) {
6296
+ this._applyChange(absoluteAddresses);
6297
+ }
6298
+ _applyChange(absoluteAddresses) {
6299
+ // Note: AbsoluteStateAddress はキャッシュされているため、
6300
+ // 同一の (stateName, address) は同じインスタンスとなり、
6301
+ // Set による重複排除が正しく機能する
6302
+ const absoluteAddressSet = new Set(absoluteAddresses);
6303
+ const processBindings = [];
6304
+ for (const absoluteAddress of absoluteAddressSet) {
6305
+ // peek: バインディングの無いアドレス(リスト置換で enqueue される中間
6306
+ // アドレス等)に空 Set を生成・蓄積しない
6307
+ const bindings = peekBindingSetByAbsoluteStateAddress(absoluteAddress);
6308
+ if (bindings === undefined) {
6309
+ continue;
6310
+ }
6311
+ for (const binding of bindings) {
6312
+ if (binding.replaceNode.isConnected === false) {
6313
+ // 切断されているバインディングは無視
6314
+ continue;
6315
+ }
6316
+ processBindings.push(binding);
6317
+ }
6318
+ }
6319
+ applyChangeFromBindings(processBindings);
6320
+ // drain 終了フック: binding 適用後に dedup 済みバッチを通知する(設計書 §3-2)。
6321
+ // testApplyChange も同じ _applyChange を通るため、テストから同期に駆動できる。
6322
+ notifyUpdateBatchListeners(absoluteAddressSet);
6323
+ }
6324
+ }
6325
+ const updater = new Updater();
6326
+ function getUpdater() {
6327
+ return updater;
6328
+ }
6329
+
6330
+ /**
6331
+ * stream/argsTrace.ts
6332
+ *
6333
+ * `$streams` の args トレース(依存捕捉、docs/state-streams-design.md §3-1)。
6334
+ *
6335
+ * - モジュールスコープの collector を立てて readonly proxy 上で args を評価し、
6336
+ * getByAddress を通った読みを絶対アドレス(IAbsoluteStateAddress)として捕捉する。
6337
+ * AbsolutePathInfo / AbsoluteStateAddress は両方キャッシュ済みのため、捕捉した
6338
+ * アドレスは drain バッチと Set.has のインスタンス同一性で O(1) 照合できる(§2-1)。
6339
+ * - collectStreamDependency は getByAddress のホットパスから毎読み呼ばれるため、
6340
+ * collector === null なら即 return し、それ以外の計算を一切しない。
6341
+ * - 起動・restart のたびに traceArgs が呼ばれ、成功時は entry.depAddresses を
6342
+ * 丸ごと置換する(per-run の動的再捕捉)。失敗時は前回成功 run の検証済み
6343
+ * 捕捉を保持する(§2-2 の「error からも依存変化で restart」を保つ)。
6344
+ * - lastNotified.ts と同じく import 循環回避のための小モジュール
6345
+ * (getByAddress → argsTrace ← streamRuntime の一方向依存に保つ)。
6346
+ */
6347
+ /** トレース中のみ非 null。getByAddress を通った読みの絶対アドレスが溜まる。 */
6348
+ let collector = null;
6349
+ /**
6350
+ * getByAddress の入口(checkDependency 直後)から毎読み呼ばれるフック。
6351
+ * トレース外(collector === null)では何もしない。
6352
+ */
6353
+ function collectStreamDependency(stateElement, address) {
6354
+ if (collector === null) {
6355
+ return;
6356
+ }
6357
+ const absolutePathInfo = getAbsolutePathInfo(stateElement, address.pathInfo);
6358
+ collector.add(createAbsoluteStateAddress(absolutePathInfo, address.listIndex));
6359
+ }
6360
+ /**
6361
+ * args を readonly proxy で同期評価し、読まれたパスを entry.depAddresses に
6362
+ * 丸ごと置換で再捕捉する(§3-1)。評価値(source の第 1 引数になる)を返す。
6363
+ *
6364
+ * - args === null(宣言で省略)なら depAddresses を clear して undefined
6365
+ * (依存なし = 起動後 restart しない)。
6366
+ * - 検査(違反は raiseError):
6367
+ * (a) 評価値が Promise(同期契約違反)
6368
+ * (b) 自己依存 — `<name>` / `$streamStatus.<name>` / `$streamError.<name>` の読み
6369
+ * (restart の自己書き込みで再発火する無限ループ、S8)
6370
+ * (c) wildcard を含むパスの読み(`$getAll` 等も同様。第 1 段スコープ外)
6371
+ * - 失敗時(args のユーザー例外・検査違反)は今回の捕捉(captured)を採用せず
6372
+ * 伝播し、entry.depAddresses には**前回成功 run の検証済み捕捉を保持する**。
6373
+ * これにより drain リスナーが throw を error 経路に正規化したあとも、依存の
6374
+ * 書き込みで再試行できる(§2-2「done / error からも依存変化で restart」——
6375
+ * 一時的な args throw で stream が恒久固着しない)。ループ安全性:
6376
+ * 保持されるのは前回**成功** run の捕捉のみ(自己依存・wildcard 検査済み)で
6377
+ * 自分の `<name>` / `$streamStatus.<name>` / `$streamError.<name>` を含み得ず、
6378
+ * traceArgs throw 時の startStream は initial リセットに到達しないため、
6379
+ * error 正規化の書き込みが保持 deps に再 hit することはない。再試行は依存
6380
+ * 書き込み 1 回につき高々 1 回で有界。未検査の captured を採用しないことが
6381
+ * ループ防止の要件であり、前回検証済み捕捉の保持はそれを侵さない。
6382
+ * - collector は finally で必ず復元する(例外・再入安全。ネスト評価は想定しないが
6383
+ * 防御的に「前の collector を復元」の形にしておく — コストは同等)。
6384
+ */
6385
+ function traceArgs(stateElement, entry) {
6386
+ const argsFn = entry.definition.args;
6387
+ if (argsFn === null) {
6388
+ entry.depAddresses.clear();
6389
+ return undefined;
6390
+ }
6391
+ const previousCollector = collector;
6392
+ const captured = new Set();
6393
+ collector = captured;
6394
+ let argsValue = undefined;
6395
+ try {
6396
+ stateElement.createState("readonly", (state) => {
6397
+ argsValue = argsFn(state);
6398
+ });
6399
+ }
6400
+ finally {
6401
+ // args のユーザー例外時は captured を採用せずそのまま伝播する
6402
+ // (entry.depAddresses は前回成功 run の検証済み捕捉を保持)
6403
+ collector = previousCollector;
6404
+ }
6405
+ if (argsValue instanceof Promise) {
6406
+ raiseError(`${STATE_STREAMS_NAME} entry "${entry.name}" args must be synchronous (it returned a Promise).`);
6407
+ }
6408
+ const selfStatusPath = `${STATE_STREAM_STATUS_NAMESPACE_NAME}${DELIMITER}${entry.name}`;
6409
+ const selfErrorPath = `${STATE_STREAM_ERROR_NAMESPACE_NAME}${DELIMITER}${entry.name}`;
6410
+ for (const dep of captured) {
6411
+ const pathInfo = dep.absolutePathInfo.pathInfo;
6412
+ if (dep.absolutePathInfo.stateElement === stateElement &&
6413
+ (pathInfo.path === entry.name || pathInfo.path === selfStatusPath || pathInfo.path === selfErrorPath)) {
6414
+ raiseError(`${STATE_STREAMS_NAME} entry "${entry.name}" args must not read the stream itself ("${pathInfo.path}"): a self-dependency would restart the stream on its own writes (infinite loop).`);
6415
+ }
6416
+ if (pathInfo.wildcardCount > 0) {
6417
+ raiseError(`${STATE_STREAMS_NAME} entry "${entry.name}" args must not read wildcard paths ("${pathInfo.path}"): wildcard dependencies are out of scope.`);
6418
+ }
6419
+ }
6420
+ entry.depAddresses = captured;
6421
+ return argsValue;
6422
+ }
6423
+
6424
+ /**
6425
+ * stream/consumeSource.ts
6426
+ *
6427
+ * `$streams` のチャンク消費ループ(docs/state-streams-design.md §3-3)。
6428
+ * packages/signals/src/streamResource.ts の consume / iterate /
6429
+ * readableToAsyncIterable の移植(パッケージ間依存は持たない自己完結原則)。
6430
+ *
6431
+ * 唯一の構造差分は状態書き込みの IConsumeSink への委譲:
6432
+ * value.set(fold(value.peek(), chunk)) → sink.fold(chunk)
6433
+ * status.set("done") → sink.done()
6434
+ * error.set(e) + status.set("error") → sink.fail(e)
6435
+ *
6436
+ * sink.fold() が throw した場合(fold throw)もループ内の throw として
6437
+ * 既存の catch に流れ、signal.aborted なら return、でなければ sink.fail(e)。
6438
+ * consumeSource 自体は fold throw と source throw を区別しない
6439
+ * (producer の掃除 = controller.abort() は呼び出し側 runtime が fail 内で行う)。
6440
+ * consumeSource は reject しない(全経路 catch 済み)。
6441
+ *
6442
+ * ---------------------------------------------------------------------------
6443
+ * 以下、移植元モジュールヘッダの契約(原文英語のまま維持):
6444
+ *
6445
+ * CONTRACT (cooperative cancellation — STRONG REQUIREMENT): the `source` MUST honor
6446
+ * the `AbortSignal` it is given. Honoring it is what drives switchMap restart/dispose;
6447
+ * a source that ignores it cannot be reliably cancelled.
6448
+ *
6449
+ * Rescue levels on abort:
6450
+ * - ReadableStream: FULLY rescued. A parked read() is force-unwound via
6451
+ * reader.cancel(), which both releases the underlying source and settles the
6452
+ * pending read() so the loop unwinds.
6453
+ * - AsyncIterable / async generator: PARTIALLY rescued. On abort we call
6454
+ * iterator.return() to trigger the generator's finally/cleanup. But a parked
6455
+ * `await` (the producer stalling before its next yield while IGNORING `signal`)
6456
+ * cannot be force-unwound from outside — return() only takes effect when the
6457
+ * generator next resumes. So a source that parks forever and never observes
6458
+ * `signal` still leaks its consume task. Honor `signal` to bound this.
6459
+ * The `if (signal.aborted) return` check only runs after a chunk arrives, not while
6460
+ * parked — it drops stale chunks but is not, by itself, a cancellation mechanism.
6461
+ */
6462
+ async function consumeSource(source, args, signal, sink) {
6463
+ // Obtain the iterator EXPLICITLY (not via `for await`'s implicit one) so abort can
6464
+ // call `iterator.return()` to trigger an AsyncIterable / async generator's
6465
+ // `finally`/cleanup. A `for await` only calls `.return()` when the loop itself exits;
6466
+ // if the producer is PARKED (awaiting before the next yield while ignoring `signal`),
6467
+ // the loop never advances, so the implicit `.return()` never runs and the task leaks
6468
+ // past restart/dispose. Calling `.return()` on abort is the PARTIAL rescue: the
6469
+ // parked `await` cannot be force-unwound from outside, but once the generator resumes
6470
+ // (its next tick), `.return()` makes it run its `finally` and stop — recovering the
6471
+ // common "generator wakes up after abort" case. The ReadableStream path is fully
6472
+ // rescued via `reader.cancel()` (see `readableToAsyncIterable`).
6473
+ let iterator = null;
6474
+ // Guard against returning the SAME iterator twice. `onAbort` is reachable two ways:
6475
+ // the abort listener, and the explicit call below when abort raced the
6476
+ // `await source(...)`. The guard keys on the iterator instance (not a plain "ran"
6477
+ // flag): the listener firing with iterator still null must NOT consume the single
6478
+ // real cleanup that the explicit call performs once the iterator exists. So we only
6479
+ // mark an iterator returned once we have actually called `.return()` on it.
6480
+ let returned = null;
6481
+ const onAbort = () => {
6482
+ if (!iterator || iterator === returned) {
6483
+ return; // nothing to release yet, or already released this iterator
6484
+ }
6485
+ returned = iterator;
6486
+ // Fire the iterator's cleanup. Swallow any throw/rejection from `.return()` — we
6487
+ // are tearing down; a producer that rejects on return must not surface here.
6488
+ try {
6489
+ void iterator.return?.()?.then?.(undefined, () => { });
6490
+ }
6491
+ catch {
6492
+ // `.return()` threw synchronously while tearing down — ignore.
6493
+ }
6494
+ };
6495
+ signal.addEventListener("abort", onAbort, { once: true });
6496
+ try {
6497
+ const produced = await source(args, signal);
6498
+ iterator = iterate(produced, signal)[Symbol.asyncIterator]();
6499
+ if (signal.aborted) {
6500
+ // Aborted while awaiting the source: the abort listener already ran (iterator
6501
+ // was still null then), so explicitly release the just-produced iterator now —
6502
+ // this fires a generator's finally / a ReadableStream's cancel for the
6503
+ // resource we created but will never iterate.
6504
+ onAbort();
6505
+ return;
6506
+ }
6507
+ for (;;) {
6508
+ const result = await iterator.next();
6509
+ if (result.done) {
6510
+ break;
6511
+ }
6512
+ if (signal.aborted) {
6513
+ return; // stale chunk from a superseded/disposed run — drop it
6514
+ }
6515
+ sink.fold(result.value);
6516
+ }
6517
+ if (signal.aborted) {
6518
+ return; // stream ended but this run was aborted — don't mark done
6519
+ }
6520
+ sink.done();
6521
+ }
6522
+ catch (e) {
6523
+ if (signal.aborted) {
6524
+ return; // an abort that surfaced as a throw is not an error
6525
+ }
6526
+ sink.fail(e); // keep the last folded value (do not reset)
6527
+ }
6528
+ finally {
6529
+ signal.removeEventListener("abort", onAbort);
6530
+ }
6531
+ }
6532
+ function iterate(produced, signal) {
6533
+ // Optional chaining: a null/undefined source return value must fall through to the
6534
+ // explicit TypeError below (symmetric with the `?.` on the getReader probe), not
6535
+ // throw an opaque "Cannot read properties of null" from this property access.
6536
+ if (typeof produced?.[Symbol.asyncIterator] === "function") {
6537
+ return produced;
6538
+ }
6539
+ // Not async-iterable: must be a ReadableStream (read via getReader). Validate so
6540
+ // a wrong source value yields a clear error instead of an opaque "getReader is
6541
+ // not a function" from inside the generator.
6542
+ if (typeof produced?.getReader !== "function") {
6543
+ throw new TypeError("[@wcstack/state] $streams: source must return an AsyncIterable or a ReadableStream (got neither).");
6544
+ }
6545
+ return readableToAsyncIterable(produced, signal);
6546
+ }
6547
+ async function* readableToAsyncIterable(stream, signal) {
6548
+ const reader = stream.getReader();
6549
+ // A ReadableStream read() does NOT observe an AbortSignal on its own. Without
6550
+ // this, a switchMap restart / dispose leaves the previous reader parked in a
6551
+ // pending read() forever, leaking the underlying source. Cancelling on abort
6552
+ // both releases the source AND settles the pending read() so the for-await
6553
+ // unwinds and the finally below can release the lock. Abort is the only
6554
+ // early-exit path for this generator (the consumer never calls .return()
6555
+ // without aborting), so this is the sole place a non-drained stream is cancelled.
6556
+ const onAbort = () => {
6557
+ void reader.cancel().catch(() => { }); // tearing down; swallow a rejected cancel
6558
+ };
6559
+ signal.addEventListener("abort", onAbort, { once: true });
6560
+ try {
6561
+ for (;;) {
6562
+ const { done, value } = await reader.read();
6563
+ if (done) {
6564
+ return;
6565
+ }
6566
+ yield value;
6567
+ }
6568
+ }
6569
+ finally {
6570
+ signal.removeEventListener("abort", onAbort);
6571
+ reader.releaseLock();
6572
+ }
6573
+ }
6574
+
6575
+ /**
6576
+ * stream/streamRuntime.ts
6577
+ *
6578
+ * `$streams` の起動・チャンク反映・status 遷移(docs/state-streams-design.md
6579
+ * §2-2 / §3-3 / §4-3)。
6580
+ *
6581
+ * スコープ:
6582
+ * - eager 起動(startStreams)と start = restart の共通手順(startStream)。
6583
+ * - args は traceArgs(stream/argsTrace.ts)で readonly proxy 評価と同時に依存を
6584
+ * per-run 再捕捉する(§3-1)。
6585
+ * - 依存駆動 restart(§3-2): モジュール初期化時に updater の drain 終了リスナーを
6586
+ * 1 つ登録し(restartStreamsOnUpdateBatch)、起動中 stateElement
6587
+ * (stream/activeStateElements.ts — startStreams で add・abortAllStreams /
6588
+ * clearStreamRegistry で delete)の各 entry について depAddresses と batch を
6589
+ * 交差させ、hit した entry を restart する。
6590
+ *
6591
+ * 切断後の後始末について(不変条件):
6592
+ * - disconnect(abortAllStreams)は registry entry を直接ミューテーションして
6593
+ * idle に戻す($postUpdate は呼ばない — 切断済みで binding 更新は不要かつ
6594
+ * rootNode が無い)。
6595
+ * - abort 済み run の sink コールバック(fold / done / fail)は consumeSource の
6596
+ * stale-drop(全経路の signal.aborted チェック)が createState 到達前に
6597
+ * 落とすため、runtime 側に切断後ガードは不要。
6598
+ * 「runtime が createState を呼ぶのは自分の controller が生きている間だけ」が
6599
+ * この 2 つの組み合わせで常に保たれる。
6600
+ */
6601
+ /**
6602
+ * 登録済みの全 stream を起動する(eager 起動、設計書 §2-3)。
6603
+ * State.connectedCallback($connectedCallback 完了後)と接続中の `_state` 再 set
6604
+ * から呼ばれる想定。
6605
+ *
6606
+ * 同時に依存駆動 restart(§3-2)の対象として activeStateElements に登録する
6607
+ * (delete 側は abortAllStreams / clearStreamRegistry —
6608
+ * stream/activeStateElements.ts のリーク防止不変条件を参照)。
6609
+ * eager 起動の throw(args のユーザー例外等)はここでは正規化せず loud fail のまま
6610
+ * (既存の $connectedCallback と同じ扱い。正規化は drain リスナー側の restart のみ)。
6611
+ */
6612
+ function startStreams(stateElement) {
6613
+ const entries = getStreamEntries(stateElement);
6614
+ if (entries.size === 0) {
6615
+ return;
6616
+ }
6617
+ addActiveStateElement(stateElement);
6618
+ for (const entry of entries.values()) {
6619
+ startStream(stateElement, entry);
6620
+ }
6621
+ }
6622
+ /**
6623
+ * stream を起動する。start = restart の共通手順(設計書 §2-2):
6624
+ *
6625
+ * 1. 旧 run を abort(restart 時)→ 新 AbortController
6626
+ * 2. traceArgs で args を readonly proxy 評価し依存を丸ごと再捕捉
6627
+ * (Promise / 自己依存 / wildcard 読みは raiseError、§3-1)
6628
+ * 3. 値を initial にリセット(起動 = 最初の run も restart と同一セマンティクス、§1-3)
6629
+ * 4. status="active"・error=null を反映
6630
+ * 5. consumeSource で消費開始
6631
+ */
6632
+ function startStream(stateElement, entry) {
6633
+ entry.controller?.abort();
6634
+ const controller = new AbortController();
6635
+ entry.controller = controller;
6636
+ // args 評価 + 依存の per-run 再捕捉(args === null なら depAddresses を clear して
6637
+ // undefined。Promise / 自己依存 / wildcard 読みは raiseError、§3-1)
6638
+ const argsValue = traceArgs(stateElement, entry);
6639
+ // 値リセット: setByAddress を通すことで updater coalesce・sameValueGuard・
6640
+ // walkDependency(stream 値に依存する computed の dirty 化)がすべて乗る(§3-3)
6641
+ stateElement.createState("writable", (state) => {
6642
+ state[entry.name] = entry.definition.initial;
6643
+ });
6644
+ updateStreamStatus(stateElement, entry, "active", null);
6645
+ const definition = entry.definition;
6646
+ const sink = {
6647
+ fold(chunk) {
6648
+ // fold の throw はそのまま伝播させる(consumeSource が fail 経路に回す)
6649
+ stateElement.createState("writable", (state) => {
6650
+ state[entry.name] = definition.fold(state[entry.name], chunk);
6651
+ });
6652
+ },
6653
+ done() {
6654
+ updateStreamStatus(stateElement, entry, "done", null);
6655
+ },
6656
+ fail(error) {
6657
+ // 値は直前の fold 結果を保持(リセットしない)
6658
+ updateStreamStatus(stateElement, entry, "error", error);
6659
+ // fold-throw 時の producer 掃除(iterator.return() / reader.cancel() を発火)。
6660
+ // source-throw 時は producer が既に終了しているので abort は無害(§3-3)。
6661
+ controller.abort();
6662
+ },
6663
+ };
6664
+ void consumeSource(definition.source, argsValue, controller.signal, sink);
6665
+ }
6666
+ /**
6667
+ * status / error の反映ヘルパ(設計書 §4-3)。
6668
+ *
6669
+ * - registry entry が正本。常に最新値へ書き換える。
6670
+ * - 「最後に通知した観測値」(stream/lastNotified.ts — 再 set・再接続を跨いで
6671
+ * stateElement の寿命で生存する台帳)から変化した項目に対応する名前空間パス
6672
+ * (`$streamStatus.<name>` / `$streamError.<name>`)だけを writable proxy の
6673
+ * $postUpdate で通知する(updater enqueue + walkDependency)。
6674
+ * - 両方不変なら通知しない(名前空間パスは setByAddress を通らないため
6675
+ * sameValueGuard が効かず、同等の same-value 判定を runtime 側が持つ)。
6676
+ * abortAllStreams の無通知ミューテーションで台帳が invalidate されている場合は
6677
+ * 同値扱いにならず必ず通知される(再接続ウィンドウ内の fresh 読みが描画した
6678
+ * idle の恒久陳腐化を防ぐ、§4-3)。
6679
+ */
6680
+ function updateStreamStatus(stateElement, entry, status, error) {
6681
+ entry.status = status;
6682
+ entry.error = error;
6683
+ const last = getLastNotified(stateElement, entry.name);
6684
+ const statusChanged = last.status !== status;
6685
+ const errorChanged = !Object.is(last.error, error);
6686
+ if (!statusChanged && !errorChanged) {
6687
+ return;
6688
+ }
6689
+ setLastNotified(stateElement, entry.name, status, error);
6690
+ stateElement.createState("writable", (state) => {
6691
+ if (statusChanged) {
6692
+ state.$postUpdate(`${STATE_STREAM_STATUS_NAMESPACE_NAME}${DELIMITER}${entry.name}`);
6693
+ }
6694
+ if (errorChanged) {
6695
+ state.$postUpdate(`${STATE_STREAM_ERROR_NAMESPACE_NAME}${DELIMITER}${entry.name}`);
6696
+ }
6697
+ });
6698
+ }
6699
+ /**
6700
+ * 依存駆動 restart の drain リスナー(設計書 §3-2)。
6701
+ * モジュール初期化時に registerUpdateBatchListener で 1 つだけ登録される。
6702
+ *
6703
+ * - 起動中の各 stateElement の各 entry について、depAddresses と batch の交差を
6704
+ * Set.has のインスタンス同一性で判定する(小さい方 = depAddresses を回して
6705
+ * batch.has(dep)。AbsoluteStateAddress はキャッシュにより同一 (stateName, path,
6706
+ * listIndex) が同一インスタンス、§2-1)。args なし(depAddresses 空)の entry は
6707
+ * 自然にスキップされる。
6708
+ * - status は問わず restart する(done / error からも依存の叩き直しで再試行、§2-2)。
6709
+ * - hit は収集してから一括で restart する(イテレーション中の registry 変更を避ける。
6710
+ * entry ごとに最初の hit で break するため「1 drain につき 1 entry 最大 1 restart」
6711
+ * もここで自然に成立する — 同一 tick 内の複数依存書き込みは 1 restart に畳まれる)。
6712
+ * - hits の実行時にも active + entry identity を再チェックする: 先行 restart の
6713
+ * source / args は consumeSource / traceArgs の同期プレフィックスで同期実行される
6714
+ * ため、そこで (a) 他の stateElement(や自分自身のホスト)の同期切断、(b) 同一要素の
6715
+ * _state 同期再 set(clearStreamRegistry → startStreams で Set に再 add される)が
6716
+ * 起こり得る。(a) は切断済み要素への startStream が rootNode 不在で throw する経路、
6717
+ * (b) は registry から置換済みの旧 entry を restart して到達不能な孤児 consume run を
6718
+ * リークする経路(§3-2「未接続の stateElement の entry は restart しない」・
6719
+ * §5-1「切断後は idle」に違反)で、いずれも「entry が現行 registry の live entry で
6720
+ * あること」の再検証で skip する。startStream **実行中**の自己切断・再 set は事前
6721
+ * チェックではガードできないため、catch 側でも同じ再検証を行ってから error に
6722
+ * 正規化する(切断済みでの正規化は createState が再 throw して drain リスナー外へ
6723
+ * 漏れ、後続 hits の restart を巻き添えにするため)。
6724
+ * - restart(startStream)は entry ごとに try/catch し、throw(args のユーザー例外・
6725
+ * Promise 同期契約違反等)は controller.abort() → status="error"・$streamError 格納
6726
+ * に正規化する(§3-2 規範 3)。updater の drain を壊さず、他 entry の restart も
6727
+ * 継続する。eager 起動(connect 時の startStreams)の throw は従来どおり loud fail。
6728
+ * - restart 内の書き込み(initial リセット・status 通知)は updater への enqueue のみで
6729
+ * 新しい microtask バッチを作る(drain 再入ではない)。自己依存は traceArgs が
6730
+ * 宣言時に raiseError で検出するため、restart 書き込みが自分の依存に再 hit する
6731
+ * ループは起きない(§3-1)。
6732
+ */
6733
+ function restartStreamsOnUpdateBatch(batch) {
6734
+ const activeStateElements = getActiveStateElements();
6735
+ if (activeStateElements.size === 0) {
6736
+ // stream 未使用アプリの drain に配列・イテレータ割り当てのコストを載せない
6737
+ return;
6738
+ }
6739
+ const hits = [];
6740
+ for (const stateElement of activeStateElements) {
6741
+ for (const entry of getStreamEntries(stateElement).values()) {
6742
+ for (const dep of entry.depAddresses) {
6743
+ if (batch.has(dep)) {
6744
+ hits.push({ stateElement, entry });
6745
+ break;
6746
+ }
6747
+ }
6748
+ }
6749
+ }
6750
+ for (const { stateElement, entry } of hits) {
6751
+ // 先行 restart の source / args 同期実行は他要素の切断や同一要素の _state 同期再 set を
6752
+ // 行い得るため、実行時に再チェックする(live な Set / registry ビューで即時反映):
6753
+ // - 切断済み要素は skip(§3-2「未接続の stateElement の entry は restart しない」)
6754
+ // - entry が現行 registry のものでなければ skip — 同期再 set で置換された旧 entry を
6755
+ // restart すると、registry から到達不能なため abortAllStreams でも止められない
6756
+ // 孤児 consume run がリークする
6757
+ if (!activeStateElements.has(stateElement) ||
6758
+ getStreamEntries(stateElement).get(entry.name) !== entry) {
6759
+ continue;
6760
+ }
6761
+ try {
6762
+ startStream(stateElement, entry);
6763
+ }
6764
+ catch (e) {
6765
+ entry.controller?.abort();
6766
+ // startStream 実行中(args / source の同期プレフィックス)の自己切断・同期再 set は
6767
+ // 上の再チェックではガードできない。切断済みだと updateStreamStatus の createState が
6768
+ // rootNode 不在で再 throw して drain リスナー外へ漏れる(後続 hits の restart を
6769
+ // 巻き添えにする)ため、entry がまだ現行の live entry である場合のみ error に
6770
+ // 正規化する(切断済みなら abortAllStreams が idle に戻し済み。§3-2 規範 3 / §5-1)。
6771
+ if (activeStateElements.has(stateElement) &&
6772
+ getStreamEntries(stateElement).get(entry.name) === entry) {
6773
+ updateStreamStatus(stateElement, entry, "error", e);
6774
+ }
6775
+ }
6776
+ }
6777
+ }
6778
+ registerUpdateBatchListener(restartStreamsOnUpdateBatch);
6779
+
6780
+ function getterFn(name) {
6781
+ return function () {
6782
+ const stateEl = this.stateElement;
6783
+ if (!stateEl)
6784
+ return undefined;
6785
+ let value;
6786
+ try {
6787
+ stateEl.createState("readonly", (state) => {
6788
+ value = state[name];
6789
+ });
6790
+ }
6791
+ catch (e) {
6792
+ console.warn(`[@wcstack/state] DCC getter "${name}" failed:`, e);
6793
+ return undefined;
6794
+ }
6795
+ return value;
6796
+ };
6797
+ }
6798
+ function setterFn(name) {
6799
+ return function (value) {
6800
+ const stateEl = this.stateElement;
6801
+ if (!stateEl)
6802
+ return;
6803
+ stateEl.initializePromise.then(() => {
6804
+ stateEl.createState("writable", (state) => {
6805
+ state[name] = value;
6806
+ });
6807
+ });
6808
+ };
6809
+ }
6810
+ function callFn(name, isAsync) {
6811
+ if (isAsync) {
6812
+ return function (...args) {
6813
+ const stateEl = this.stateElement;
6814
+ if (!stateEl)
6815
+ return undefined;
6816
+ return stateEl.initializePromise.then(() => {
6817
+ let result;
6818
+ return stateEl.createStateAsync("writable", async (state) => {
6819
+ result = await state[name](...args);
6820
+ }).then(() => result);
6821
+ });
6822
+ };
6823
+ }
6824
+ return function (...args) {
6825
+ const stateEl = this.stateElement;
6826
+ if (!stateEl)
6827
+ return undefined;
6828
+ return stateEl.initializePromise.then(() => {
6829
+ let result;
6830
+ stateEl.createState("writable", (state) => {
6831
+ result = state[name](...args);
6832
+ });
6833
+ return result;
6834
+ });
6835
+ };
6836
+ }
6837
+ function isInternalProperty(name) {
6838
+ return name.startsWith("$");
6839
+ }
6840
+
6841
+ function createWcBindable(tagName, bindables) {
6842
+ const properties = bindables.map((propName) => ({
6843
+ name: propName,
6844
+ event: `${tagName}:${propName}-changed`,
6845
+ }));
6846
+ return {
6847
+ protocol: "wc-bindable",
6848
+ version: 1,
6849
+ properties,
6850
+ };
6851
+ }
6852
+ function createBindableEventMap(tagName, bindables) {
6853
+ const map = {};
6854
+ for (const propName of bindables) {
6855
+ map[propName] = `${tagName}:${propName}-changed`;
6856
+ }
6857
+ return map;
6858
+ }
6859
+
6860
+ function defineDCC(hostElement, shadowRoot, state) {
6861
+ const tagName = hostElement.tagName.toLowerCase();
6862
+ // バリデーション
6863
+ if (!tagName.includes("-")) {
6864
+ raiseError(`DCC: "${tagName}" is not a valid custom element name (must contain a hyphen).`);
6865
+ }
6866
+ if (customElements.get(tagName)) {
6867
+ // 既に登録済みならスキップ(重複定義の検知のため警告は出す)
6868
+ console.warn(`[@wcstack/state] DCC: "${tagName}" is already registered. Skipping redefinition.`);
5715
6869
  return;
5716
6870
  }
5717
6871
  // ShadowRoot は cloneNode 不可のため、template 経由で内容をクローン
@@ -5949,7 +7103,8 @@ function dirtyCacheEntryByAbsoluteStateAddress(address) {
5949
7103
  function checkDependency(handler, address) {
5950
7104
  // 動的依存関係の登録
5951
7105
  if (handler.addressStackLength > 0) {
5952
- const lastInfo = handler.lastAddressStack?.pathInfo ?? null;
7106
+ const lastAddress = handler.lastAddressStack;
7107
+ const lastInfo = lastAddress?.pathInfo ?? null;
5953
7108
  const stateElement = handler.stateElement;
5954
7109
  if (lastInfo !== null) {
5955
7110
  if (stateElement.getterPaths.has(lastInfo.path) &&
@@ -5957,6 +7112,27 @@ function checkDependency(handler, address) {
5957
7112
  // lastInfo.pathはgetterの名前であり、address.pathInfo.pathは
5958
7113
  // そのgetterが参照している値のパスである
5959
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
+ }
5960
7136
  }
5961
7137
  }
5962
7138
  }
@@ -5981,19 +7157,42 @@ function checkDependency(handler, address) {
5981
7157
  * - ワイルドカードや多重ループにも柔軟に対応し、再帰的な値取得を実現
5982
7158
  * - finallyでキャッシュへの格納を保証
5983
7159
  */
5984
- function _getByAddress(target, address, receiver, handler, stateElement) {
5985
- if (address.pathInfo.segments[0] === STATE_COMMAND_NAMESPACE_NAME) {
5986
- // $command 名前空間配下のパスは raw state を持たないため、proxy の get トラップと
5987
- // 同じ namespace を辿る。1セグメント目は namespace 本体、2セグメント目以降は
5988
- // namespace 上のキー (= 宣言済み command token 名) を順に走査する。
5989
- let value = getCommandNamespace(stateElement);
5990
- for (let i = 1; i < address.pathInfo.segments.length; i++) {
5991
- if (value == null) {
5992
- return undefined;
5993
- }
5994
- value = Reflect.get(value, address.pathInfo.segments[i]);
7160
+ /**
7161
+ * namespace 配下のパスは raw state を持たないため、proxy の get トラップと同じ
7162
+ * namespace オブジェクトを辿る。1セグメント目は namespace 本体、2セグメント目以降は
7163
+ * namespace 上のキーを順に走査する。走査値が object / function 以外(null /
7164
+ * undefined / primitive の葉)になったら undefined を返す — 葉より深い読み
7165
+ * (例: `$streamStatus.<name>.<key>`、error が primitive throw のときの
7166
+ * `$streamError.<name>.message`)は宣言外アクセスと同じ undefined 解決とし、
7167
+ * Reflect.get non-object TypeError を updater の drain に漏らさない
7168
+ * (§4-1 の throw しない寛容規約)。
7169
+ */
7170
+ function walkNamespace(namespace, segments) {
7171
+ let value = namespace;
7172
+ for (let i = 1; i < segments.length; i++) {
7173
+ // Object(v) !== v は「v が object / function でない」(= primitive / null / undefined)判定
7174
+ if (Object(value) !== value) {
7175
+ return undefined;
5995
7176
  }
5996
- return value;
7177
+ value = Reflect.get(value, segments[i]);
7178
+ }
7179
+ return value;
7180
+ }
7181
+ function _getByAddress(target, address, receiver, handler, stateElement) {
7182
+ const firstSegment = address.pathInfo.segments[0];
7183
+ if (firstSegment === STATE_COMMAND_NAMESPACE_NAME) {
7184
+ // $command 名前空間: キーは宣言済み command token 名
7185
+ return walkNamespace(getCommandNamespace(stateElement), address.pathInfo.segments);
7186
+ }
7187
+ if (firstSegment === STATE_STREAM_STATUS_NAMESPACE_NAME) {
7188
+ // $streamStatus / $streamError 名前空間: キーは宣言済み stream 名
7189
+ // (registry entry が正本の thin gateway、docs/state-streams-design.md §4-2)。
7190
+ // setByAddress の親走査もここを通るため、子への Reflect.set が namespace proxy の
7191
+ // raiseError に到達する = 書き込み防御(S11)もこの分岐で成立する。
7192
+ return walkNamespace(getStreamStatusNamespace(stateElement), address.pathInfo.segments);
7193
+ }
7194
+ if (firstSegment === STATE_STREAM_ERROR_NAMESPACE_NAME) {
7195
+ return walkNamespace(getStreamErrorNamespace(stateElement), address.pathInfo.segments);
5997
7196
  }
5998
7197
  if (address.pathInfo.path in target) {
5999
7198
  // getterの中で参照の可能性があるので、addressをプッシュする
@@ -6039,6 +7238,8 @@ function _getByAddressWithCache(target, address, receiver, handler, stateElement
6039
7238
  }
6040
7239
  function getByAddress(target, address, receiver, handler) {
6041
7240
  checkDependency(handler, address);
7241
+ // $streams の args トレース中のみ絶対アドレスを捕捉(collector 非活性なら即 return)
7242
+ collectStreamDependency(handler.stateElement, address);
6042
7243
  const stateElement = handler.stateElement;
6043
7244
  const cacheable = address.pathInfo.wildcardCount > 0 ||
6044
7245
  stateElement.getterPaths.has(address.pathInfo.path);
@@ -6082,49 +7283,6 @@ function getContextListIndex(handler, structuredPath) {
6082
7283
  return address.listIndex?.at(index) ?? null;
6083
7284
  }
6084
7285
 
6085
- class Updater {
6086
- _queueAbsoluteAddresses = [];
6087
- constructor() {
6088
- }
6089
- enqueueAbsoluteAddress(absoluteAddress) {
6090
- const requireStartProcess = this._queueAbsoluteAddresses.length === 0;
6091
- this._queueAbsoluteAddresses.push(absoluteAddress);
6092
- if (requireStartProcess) {
6093
- queueMicrotask(() => {
6094
- const absoluteAddresses = this._queueAbsoluteAddresses;
6095
- this._queueAbsoluteAddresses = [];
6096
- this._applyChange(absoluteAddresses);
6097
- });
6098
- }
6099
- }
6100
- // テスト用に公開
6101
- testApplyChange(absoluteAddresses) {
6102
- this._applyChange(absoluteAddresses);
6103
- }
6104
- _applyChange(absoluteAddresses) {
6105
- // Note: AbsoluteStateAddress はキャッシュされているため、
6106
- // 同一の (stateName, address) は同じインスタンスとなり、
6107
- // Set による重複排除が正しく機能する
6108
- const absoluteAddressSet = new Set(absoluteAddresses);
6109
- const processBindings = [];
6110
- for (const absoluteAddress of absoluteAddressSet) {
6111
- const bindings = getBindingSetByAbsoluteStateAddress(absoluteAddress);
6112
- for (const binding of bindings) {
6113
- if (binding.replaceNode.isConnected === false) {
6114
- // 切断されているバインディングは無視
6115
- continue;
6116
- }
6117
- processBindings.push(binding);
6118
- }
6119
- }
6120
- applyChangeFromBindings(processBindings);
6121
- }
6122
- }
6123
- const updater = new Updater();
6124
- function getUpdater() {
6125
- return updater;
6126
- }
6127
-
6128
7286
  const swapInfoByStateAddress = new WeakMap();
6129
7287
  function getSwapInfoByAddress(address) {
6130
7288
  return swapInfoByStateAddress.get(address) ?? null;
@@ -6177,6 +7335,37 @@ function _walkExpandWildcard(context, currentWildcardIndex, parentListIndex) {
6177
7335
  }
6178
7336
  }
6179
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
+ }
6180
7369
  function _walkDependency(context, startAddress, callback) {
6181
7370
  const stack = [{ address: startAddress, depth: 0 }];
6182
7371
  while (stack.length > 0) {
@@ -6209,7 +7398,7 @@ function _walkDependency(context, startAddress, callback) {
6209
7398
  const absAddress = createAbsoluteStateAddress(absPathInfo, address.listIndex);
6210
7399
  const lastValue = getLastListValueByAbsoluteStateAddress(absAddress);
6211
7400
  const listDiff = createListDiff(address.listIndex, lastValue, newValue);
6212
- for (const listIndex of listDiff.newIndexes) {
7401
+ for (const listIndex of selectExpansionIndexes(context, sourcePath, lastValue, newValue, listDiff)) {
6213
7402
  const depAddress = createStateAddress(depPathInfo, listIndex);
6214
7403
  context.result.add(depAddress);
6215
7404
  nextEntries.push({ address: depAddress, depth: nextDepth });
@@ -6303,7 +7492,7 @@ function _walkDependency(context, startAddress, callback) {
6303
7492
  }
6304
7493
  }
6305
7494
  }
6306
- function walkDependency(stateName, stateElement, startAddress, staticDependency, dynamicDependency, listPathSet, stateProxy, searchType, callback) {
7495
+ function walkDependency(stateName, stateElement, startAddress, staticDependency, dynamicDependency, listPathSet, stateProxy, searchType, callback, options) {
6307
7496
  const context = {
6308
7497
  stateElement: stateElement,
6309
7498
  staticMap: staticDependency,
@@ -6313,6 +7502,7 @@ function walkDependency(stateName, stateElement, startAddress, staticDependency,
6313
7502
  visited: new Set(),
6314
7503
  stateProxy: stateProxy,
6315
7504
  searchType: searchType,
7505
+ listExpansion: options?.listExpansion ?? "full",
6316
7506
  };
6317
7507
  _walkDependency(context, startAddress, callback);
6318
7508
  return Array.from(context.result);
@@ -6378,7 +7568,10 @@ function _setByAddress(target, address, absAddress, value, receiver, handler) {
6378
7568
  dirtyCacheEntryByAbsoluteStateAddress(absDepAddress);
6379
7569
  // 更新対象として登録
6380
7570
  updater.enqueueAbsoluteAddress(absDepAddress);
6381
- });
7571
+ },
7572
+ // リスト置換時は追加行・位置変更行のみ展開する(未変更行の再訪を省く。
7573
+ // $postUpdate の手動リフレッシュは従来通り全行展開のまま)
7574
+ { listExpansion: "diff" });
6382
7575
  }
6383
7576
  }
6384
7577
  function _setByAddressWithSwap(target, address, absAddress, value, receiver, handler) {
@@ -6821,6 +8014,9 @@ async function setLoopContextAsync(handler, loopContext, callback) {
6821
8014
  * - 通常のプロパティアクセスもバインディングや多重ループに対応
6822
8015
  * - シンボルAPIやReflect.getで拡張性・互換性も確保
6823
8016
  */
8017
+ // `$streamStatus.<name>` / `$streamError.<name>` の dotted パス判定用プレフィックス
8018
+ const STREAM_STATUS_PATH_PREFIX = `${STATE_STREAM_STATUS_NAMESPACE_NAME}${DELIMITER}`;
8019
+ const STREAM_ERROR_PATH_PREFIX = `${STATE_STREAM_ERROR_NAMESPACE_NAME}${DELIMITER}`;
6824
8020
  function get(target, prop, receiver, handler) {
6825
8021
  const index = INDEX_BY_INDEX_NAME[prop];
6826
8022
  if (typeof index !== "undefined") {
@@ -6859,14 +8055,27 @@ function get(target, prop, receiver, handler) {
6859
8055
  case STATE_COMMAND_NAMESPACE_NAME: {
6860
8056
  return getCommandNamespace(handler.stateElement);
6861
8057
  }
8058
+ case STATE_STREAM_STATUS_NAMESPACE_NAME: {
8059
+ return getStreamStatusNamespace(handler.stateElement);
8060
+ }
8061
+ case STATE_STREAM_ERROR_NAMESPACE_NAME: {
8062
+ return getStreamErrorNamespace(handler.stateElement);
8063
+ }
8064
+ }
8065
+ // switch 不一致の $ プロパティのうち、`$streamStatus.<name>` / `$streamError.<name>`
8066
+ // の dotted パスだけは通常のパス解決(getByAddress)へフォールスルーさせる。
8067
+ // これが computed(getter)内での依存追跡付き読み取りの正規形
8068
+ // (checkDependency が getter スコープで動的依存を登録し、$postUpdate の
8069
+ // walkDependency で computed が無効化される、docs/state-streams-design.md §4-3)。
8070
+ // それ以外の未知 $ プロパティは従来どおり undefined を返す。
8071
+ if (!prop.startsWith(STREAM_STATUS_PATH_PREFIX) && !prop.startsWith(STREAM_ERROR_PATH_PREFIX)) {
8072
+ return undefined;
6862
8073
  }
6863
8074
  }
6864
- else {
6865
- const resolvedAddress = getResolvedAddress(prop);
6866
- const listIndex = getListIndex(target, resolvedAddress, receiver, handler);
6867
- const stateAddress = createStateAddress(resolvedAddress.pathInfo, listIndex);
6868
- return getByAddress(target, stateAddress, receiver, handler);
6869
- }
8075
+ const resolvedAddress = getResolvedAddress(prop);
8076
+ const listIndex = getListIndex(target, resolvedAddress, receiver, handler);
8077
+ const stateAddress = createStateAddress(resolvedAddress.pathInfo, listIndex);
8078
+ return getByAddress(target, stateAddress, receiver, handler);
6870
8079
  }
6871
8080
  else if (typeof prop === "symbol") {
6872
8081
  switch (prop) {
@@ -7441,6 +8650,10 @@ class State extends HTMLElement {
7441
8650
  return getBindingsReady(rootNode);
7442
8651
  }
7443
8652
  __state;
8653
+ _hasUpdatedCallback = false;
8654
+ // 他行を読む getter が検出されたリストパス(diff-filter 展開の全行フォールバック対象)。
8655
+ // 依存マップ(static/dynamic)と同様に追加のみ・クリアしない(安全側に固定される)。
8656
+ _crossRowListPaths = new Set();
7444
8657
  _name = 'default';
7445
8658
  _initialized = false;
7446
8659
  _initializePromise;
@@ -7466,6 +8679,17 @@ class State extends HTMLElement {
7466
8679
  _bindableEventMap = {};
7467
8680
  _commandTokenNames = new Set();
7468
8681
  _eventTokenNames = new Set();
8682
+ _dcc = false;
8683
+ // connect サイクルの世代カウンタ(connectedCallback 冒頭でインクリメント)。
8684
+ // $connectedCallback の await 中の「切断 → 即再接続」では、新 connect が
8685
+ // _rootNode を再設定済みのため陳腐化した旧 connect の再開が _rootNode ガードを
8686
+ // 素通りして startStreams に到達し、同一の再接続に対して source が二重起動する。
8687
+ // 末尾で冒頭に捕捉した世代と照合し、陳腐 connect からの起動を skip する(設計書 §2-3)。
8688
+ _connectGeneration = 0;
8689
+ // _state セッター側の startStreams が走った connect 世代
8690
+ // (connectedCallback 末尾の startStreams との二重起動防止、設計書 §2-3。
8691
+ // 世代が進めば不一致となり自然に無効化される — サイクル単位のフラグリセット相当)
8692
+ _streamsStartedGeneration = 0;
7469
8693
  constructor() {
7470
8694
  super();
7471
8695
  this._initializePromise = new Promise((resolve) => {
@@ -7491,12 +8715,22 @@ class State extends HTMLElement {
7491
8715
  this._commandTokenNames = processCommandTokensDeclaration(value);
7492
8716
  this._eventTokenNames = processEventTokensDeclaration(value);
7493
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;
7494
8725
  // 再 set 時に二重 subscribe しないよう registry をクリアしてから $on を配線し直す。
7495
8726
  clearEventTokenRegistry(this);
7496
8727
  processOnDeclaration(this, value, this._eventTokenNames);
7497
8728
  this._listPaths.clear();
7498
8729
  this._elementPaths.clear();
7499
8730
  this._getterPaths.clear();
8731
+ // 再 set 時の残骸が $streams の衝突検査(processStreamsDeclaration)に
8732
+ // 偽陽性で命中しないよう getterPaths と対称にクリアする。
8733
+ this._setterPaths.clear();
7500
8734
  this._pathSet.clear();
7501
8735
  const stateInfo = getStateInfo(value);
7502
8736
  for (const path of stateInfo.getterPaths) {
@@ -7505,6 +8739,25 @@ class State extends HTMLElement {
7505
8739
  for (const path of stateInfo.setterPaths) {
7506
8740
  this._setterPaths.add(path);
7507
8741
  }
8742
+ // $streams: 再 set 時の二重起動防止のため旧 stream を abort + registry 全削除してから
8743
+ // 新宣言をパースする(clearEventTokenRegistry → processOnDeclaration と同じ再配線パターン)。
8744
+ // getterPaths / setterPaths の収集後であること(宣言バリデーションが衝突検査で参照する)。
8745
+ // namespace proxy の memo も破棄して古い proxy を捨てる(clearCommandNamespace と対称)。
8746
+ clearStreamNamespace(this);
8747
+ clearStreamRegistry(this);
8748
+ processStreamsDeclaration(this, value);
8749
+ // 接続中の再 set(S13)は新宣言で即再起動する。
8750
+ // 初回(_initialize 中)は _initialized が false なのでここでは起動されず、
8751
+ // connectedCallback 側の startStreams($connectedCallback 完了後)が担う。
8752
+ if (this._initialized && this._rootNode !== null && !inSsr()) {
8753
+ startStreams(this);
8754
+ // $connectedCallback 実行中の再 set(setInitialState)では、ここで新宣言が
8755
+ // 起動済みのため connectedCallback 末尾の startStreams を skip させる。
8756
+ // skip しないと同一 connect サイクルで新宣言の source が 2 回起動する
8757
+ // (1 回目は即 abort — switchMap 意味論で状態は壊れないが、副作用を持つ
8758
+ // source が 2 回発火してしまう)。
8759
+ this._streamsStartedGeneration = this._connectGeneration;
8760
+ }
7508
8761
  this._resolveLoading?.();
7509
8762
  }
7510
8763
  get name() {
@@ -7655,6 +8908,7 @@ class State extends HTMLElement {
7655
8908
  raiseError(`DCC: Failed to load state: ${e}`);
7656
8909
  }
7657
8910
  defineDCC(hostElement, shadowRoot, state);
8911
+ this._dcc = true;
7658
8912
  this._initialized = true;
7659
8913
  this._rootNode = null; // disconnectedCallbackでのstate参照を防止
7660
8914
  this._resolveInitialize?.();
@@ -7670,6 +8924,11 @@ class State extends HTMLElement {
7670
8924
  }
7671
8925
  async connectedCallback() {
7672
8926
  this._rootNode = this.getRootNode();
8927
+ // connect 世代を進めて冒頭で捕捉する(末尾の startStreams 前に照合し、
8928
+ // $connectedCallback の await 中に「切断 → 即再接続」された陳腐 connect の
8929
+ // 再開からの起動を防ぐ)。前回接続中の再 set(S13)で立った
8930
+ // _streamsStartedGeneration も世代不一致となり自然に無効化される。
8931
+ const connectGeneration = ++this._connectGeneration;
7673
8932
  if (!this._initialized) {
7674
8933
  // DCC 検出: ShadowRoot 内かつホストに data-wc-definition がある場合
7675
8934
  const parentNode = this.parentNode;
@@ -7683,6 +8942,12 @@ class State extends HTMLElement {
7683
8942
  this._initialized = true;
7684
8943
  this._resolveInitialize?.();
7685
8944
  }
8945
+ else if (!this._dcc && getStateElementByName(this._rootNode, this._name) !== this) {
8946
+ // 再接続(disconnect で名前登録が解除された後の再 connect): 登録を復元する。
8947
+ // createState が rootNode 経由でこの要素を解決できるようにするために必要
8948
+ // ($connectedCallback の再実行と $streams の initial からの再起動が依存する、設計書 §2-3)。
8949
+ setStateElementByName(this._rootNode, this._name, this);
8950
+ }
7686
8951
  // enable-ssr (クライアント側): SSR で $connectedCallback 済みなのでスキップ
7687
8952
  // inSsr() (サーバー側): レンダリング中なので実行する
7688
8953
  if (!this.hasAttribute('enable-ssr') || inSsr()) {
@@ -7699,16 +8964,53 @@ class State extends HTMLElement {
7699
8964
  Ssr.buildContent(ssrEl, stateData);
7700
8965
  this.parentNode?.insertBefore(ssrEl, this);
7701
8966
  }
8967
+ // $streams の eager 起動($connectedCallback 完了後、設計書 §2-3)。
8968
+ // inSsr() 時は起動しない(SSR 出力には initial が乗る、§7-1)。
8969
+ // enable-ssr のクライアント側は $connectedCallback をスキップしても起動する
8970
+ // (stream はシリアライズ不能なランタイム副作用のため)。
8971
+ // _rootNode ガード: $connectedCallback の await 中に切断された場合は起動しない。
8972
+ // ガードなしだと startStream 内の createState が rootNode 解決(disconnectedCallback
8973
+ // で null 化済み)の raiseError で throw し、connectedCallbackPromise が永遠に
8974
+ // 未解決になる。「未接続の entry は restart しない」設計書 §3-2 とも整合し、
8975
+ // _state セッター側の startStreams 前ガード(_rootNode !== null)と対称。
8976
+ // 世代ガード(connectGeneration 照合): await 中に「切断 → 即再接続」された場合、
8977
+ // 新 connect が _rootNode を再設定済みで上のガードを素通りするため、世代不一致で
8978
+ // 陳腐化した connect の再開を検出して skip する。起動点が新 connect の末尾に
8979
+ // 一本化され、「$connectedCallback 完了後に起動」(S1)の順序保証も保たれる。
8980
+ // _streamsStartedGeneration ガード: $connectedCallback 内の setInitialState
8981
+ // (接続中の再 set)で _state セッター側が新宣言を起動済みの場合は skip する
8982
+ // (skip しないと同一 connect サイクルで source が 2 回起動する、設計書 §2-3)。
8983
+ if (!inSsr() &&
8984
+ this._rootNode !== null &&
8985
+ connectGeneration === this._connectGeneration &&
8986
+ this._streamsStartedGeneration !== connectGeneration) {
8987
+ startStreams(this);
8988
+ }
7702
8989
  this._resolveConnectedCallback?.();
7703
8990
  }
7704
8991
  disconnectedCallback() {
7705
8992
  if (this._rootNode !== null) {
7706
- this._callStateDisconnectedCallback();
7707
- setStateElementByName(this.rootNode, this._name, null);
7708
- clearCommandTokenRegistry(this);
7709
- clearCommandNamespace(this);
7710
- clearEventTokenRegistry(this);
7711
- this._rootNode = null;
8993
+ // try/finally: ユーザーの $disconnectedCallback が throw しても後続の後始末を
8994
+ // 必ず実行する。特に abortAllStreams が飛ぶと stream が消費を続け(ゾンビ I/O)、
8995
+ // activeStateElements の強参照残留で GC が妨げられ、切断済み要素が依存駆動
8996
+ // restart の対象にも残る(設計書 §3-2 / §5-1 違反)。throw 自体は従来どおり
8997
+ // 呼び出し元へ伝播させる(変わるのは後始末の保証のみ)。
8998
+ try {
8999
+ this._callStateDisconnectedCallback();
9000
+ }
9001
+ finally {
9002
+ setStateElementByName(this.rootNode, this._name, null);
9003
+ clearCommandTokenRegistry(this);
9004
+ clearCommandNamespace(this);
9005
+ clearEventTokenRegistry(this);
9006
+ // stream は abort のみで registry は保持する(再接続時に同じ宣言から
9007
+ // initial で再起動できる、設計書 §5-1 / §5-2)。
9008
+ // namespace proxy の memo は破棄する(clearCommandNamespace と対称。
9009
+ // registry は残るため再接続後の初回アクセスで同内容の proxy が再生成される)。
9010
+ abortAllStreams(this);
9011
+ clearStreamNamespace(this);
9012
+ this._rootNode = null;
9013
+ }
7712
9014
  }
7713
9015
  }
7714
9016
  get initializePromise() {
@@ -7842,8 +9144,20 @@ class State extends HTMLElement {
7842
9144
  this._version++;
7843
9145
  return this._version;
7844
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
+ }
7845
9156
  bindProperty(prop, desc) {
7846
9157
  Object.defineProperty(this._state, prop, desc);
9158
+ if (prop === STATE_UPDATED_CALLBACK_NAME) {
9159
+ this._hasUpdatedCallback = true;
9160
+ }
7847
9161
  }
7848
9162
  setInitialState(state) {
7849
9163
  if (!this._initialized) {