@wcstack/state 1.19.0 → 1.20.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
@@ -1,10 +1,12 @@
1
- let _inSsrCache = null;
2
1
  function inSsr() {
3
- if (_inSsrCache !== null)
4
- return _inSsrCache;
5
- const html = document.querySelector('html');
6
- _inSsrCache = html ? html.hasAttribute('data-wcs-server') : false;
7
- return _inSsrCache;
2
+ // キャッシュしない: SSR モードはプロセスの属性ではなく「現在の document」の
3
+ // 属性。@wcstack/server はグローバル document を差し替えてサーバーレンダリング
4
+ // した後、同一プロセスでクライアント側ハイドレーションが走る(SSR→hydrate
5
+ // e2e が該当)。サーバーフェーズの判定をキャッシュするとクライアントフェーズが
6
+ // SSR モード扱いになり、hydrateBindings の代わりに buildBindings が選ばれて
7
+ // connectedCallbackPromise が永久に未解決になる。
8
+ const html = document.documentElement;
9
+ return html ? html.hasAttribute('data-wcs-server') : false;
8
10
  }
9
11
  const _config = {
10
12
  bindAttributeName: 'data-wcs',
@@ -63,16 +65,6 @@ function setConfig(partialConfig) {
63
65
  }
64
66
  }
65
67
 
66
- var version$1 = "1.19.0";
67
- var pkg = {
68
- version: version$1};
69
-
70
- const VERSION = pkg.version;
71
-
72
- function raiseError(message) {
73
- throw new Error(`[@wcstack/state] ${message}`);
74
- }
75
-
76
68
  const bindingPromiseByNode = new WeakMap();
77
69
  let id$1 = 0;
78
70
  function getInitializeBindingPromiseByNode(node) {
@@ -101,6 +93,10 @@ function resolveInitializedBinding(node) {
101
93
  bindingPromise.resolve();
102
94
  }
103
95
 
96
+ function raiseError(message) {
97
+ throw new Error(`[@wcstack/state] ${message}`);
98
+ }
99
+
104
100
  function replaceToReplaceNode(bindingInfo) {
105
101
  const node = bindingInfo.node;
106
102
  const replaceNode = bindingInfo.replaceNode;
@@ -1591,6 +1587,37 @@ function parseBindTextForEmbeddedNode(bindText) {
1591
1587
  };
1592
1588
  }
1593
1589
 
1590
+ const fragmentInfoByUUID = new Map();
1591
+ function setFragmentInfoByUUID(uuid, rootNode, fragmentInfo) {
1592
+ if (fragmentInfo === null) {
1593
+ fragmentInfoByUUID.delete(uuid);
1594
+ }
1595
+ else {
1596
+ fragmentInfoByUUID.set(uuid, fragmentInfo);
1597
+ const bindingPartial = fragmentInfo.parseBindTextResult;
1598
+ const stateElement = getStateElementByName(rootNode, bindingPartial.stateName);
1599
+ if (stateElement === null) {
1600
+ raiseError(`State element with name "${bindingPartial.stateName}" not found for fragment info.`);
1601
+ }
1602
+ stateElement.setPathInfo(bindingPartial.statePathName, bindingPartial.bindingType);
1603
+ for (const nodeInfo of fragmentInfo.nodeInfos) {
1604
+ for (const nodeBindingPartial of nodeInfo.parseBindTextResults) {
1605
+ const nodeStateElement = getStateElementByName(rootNode, nodeBindingPartial.stateName);
1606
+ if (nodeStateElement === null) {
1607
+ raiseError(`State element with name "${nodeBindingPartial.stateName}" not found for fragment info node.`);
1608
+ }
1609
+ nodeStateElement.setPathInfo(nodeBindingPartial.statePathName, nodeBindingPartial.bindingType);
1610
+ }
1611
+ }
1612
+ }
1613
+ }
1614
+ function getFragmentInfoByUUID(uuid) {
1615
+ return fragmentInfoByUUID.get(uuid) || null;
1616
+ }
1617
+ function getAllFragmentUUIDs() {
1618
+ return Array.from(fragmentInfoByUUID.keys());
1619
+ }
1620
+
1594
1621
  function getParseBindTextResults(node) {
1595
1622
  if (node.nodeType === Node.ELEMENT_NODE) {
1596
1623
  const element = node;
@@ -1773,6 +1800,23 @@ function createStateAddress(pathInfo, listIndex) {
1773
1800
  }
1774
1801
  }
1775
1802
 
1803
+ /**
1804
+ * devtools/sink.ts
1805
+ *
1806
+ * 計装点が参照するホットパス唯一の接点。依存ゼロの葉モジュールにすることで、
1807
+ * 計装される側(stateElementByName / setByAddress / binding / token)と
1808
+ * bridge の間の循環 import を避ける。
1809
+ *
1810
+ * コスト規範(protocol §1-1): フック未接続時、計装点のコストは
1811
+ * `devtoolsSink !== null` の分岐 1 個。イベントオブジェクトの生成は
1812
+ * 必ずこのチェックの内側で行うこと。
1813
+ */
1814
+ /** live binding としてエクスポート。計装点は `if (devtoolsSink !== null)` で参照する */
1815
+ let devtoolsSink = null;
1816
+ function setDevtoolsSink(sink) {
1817
+ devtoolsSink = sink;
1818
+ }
1819
+
1776
1820
  // command-token / event-token が共有する pub/sub プリミティブ。
1777
1821
  // _subscribers は Set のため挿入順を保持する。
1778
1822
  // emit() は subscribe() された順に呼び出され、戻り値配列も同じ順序で返る。
@@ -1812,7 +1856,31 @@ class Token {
1812
1856
 
1813
1857
  // CommandToken は共有 pub/sub プリミティブ Token の薄い特化。
1814
1858
  // instanceof による型判別を成立させるため独立クラスとして維持する。
1859
+ //
1860
+ // ownerStateName は devtools 計装(protocol §4.5)のための内部 optional 引数。
1861
+ // command-token-protocol の外部仕様は不変更(registry が渡すだけで、
1862
+ // subscribe/emit の意味論には一切影響しない)。
1815
1863
  class CommandToken extends Token {
1864
+ _ownerStateName;
1865
+ constructor(name, ownerStateName) {
1866
+ super(name);
1867
+ this._ownerStateName = ownerStateName ?? null;
1868
+ }
1869
+ emit(...args) {
1870
+ if (devtoolsSink !== null) {
1871
+ // subscriberCount 0 の emit(空撃ち)もそのまま流す — whenDefined 前の
1872
+ // command 空撃ちレース類をタイムラインで可視化するため
1873
+ devtoolsSink({
1874
+ type: "state:token-emit",
1875
+ kind: "command",
1876
+ stateName: this._ownerStateName,
1877
+ tokenName: this.name,
1878
+ args,
1879
+ subscriberCount: this.size,
1880
+ });
1881
+ }
1882
+ return super.emit(...args);
1883
+ }
1816
1884
  }
1817
1885
  function isCommandToken(value) {
1818
1886
  return value instanceof CommandToken;
@@ -1956,7 +2024,28 @@ function attachEventHandler(binding) {
1956
2024
 
1957
2025
  // EventToken は共有 pub/sub プリミティブ Token の薄い特化(element→state 方向)。
1958
2026
  // instanceof による型判別を成立させるため独立クラスとして維持する。
2027
+ //
2028
+ // ownerStateName は devtools 計装(protocol §4.5)のための内部 optional 引数。
2029
+ // event-token-protocol の外部仕様は不変更。
1959
2030
  class EventToken extends Token {
2031
+ _ownerStateName;
2032
+ constructor(name, ownerStateName) {
2033
+ super(name);
2034
+ this._ownerStateName = ownerStateName ?? null;
2035
+ }
2036
+ emit(...args) {
2037
+ if (devtoolsSink !== null) {
2038
+ devtoolsSink({
2039
+ type: "state:token-emit",
2040
+ kind: "event",
2041
+ stateName: this._ownerStateName,
2042
+ tokenName: this.name,
2043
+ args,
2044
+ subscriberCount: this.size,
2045
+ });
2046
+ }
2047
+ return super.emit(...args);
2048
+ }
1960
2049
  }
1961
2050
 
1962
2051
  const registryByStateElement$2 = new WeakMap();
@@ -1968,7 +2057,7 @@ function getOrCreateEventToken(stateElement, name) {
1968
2057
  }
1969
2058
  let token = registry.get(name);
1970
2059
  if (typeof token === "undefined") {
1971
- token = new EventToken(name);
2060
+ token = new EventToken(name, stateElement.name);
1972
2061
  registry.set(name, token);
1973
2062
  }
1974
2063
  return token;
@@ -2604,7 +2693,7 @@ function getUUID() {
2604
2693
  return `u${(count++).toString(36)}`;
2605
2694
  }
2606
2695
 
2607
- let version = 0;
2696
+ let version$1 = 0;
2608
2697
  class ListIndex {
2609
2698
  uuid = getUUID();
2610
2699
  parentListIndex;
@@ -2625,7 +2714,7 @@ class ListIndex {
2625
2714
  this.position = parentListIndex ? parentListIndex.position + 1 : 0;
2626
2715
  this.length = this.position + 1;
2627
2716
  this._index = index;
2628
- this._version = version;
2717
+ this._version = version$1;
2629
2718
  }
2630
2719
  /**
2631
2720
  * Gets current index value.
@@ -2642,7 +2731,7 @@ class ListIndex {
2642
2731
  */
2643
2732
  set index(value) {
2644
2733
  this._index = value;
2645
- this._version = ++version;
2734
+ this._version = ++version$1;
2646
2735
  this.indexes[this.position] = value;
2647
2736
  }
2648
2737
  /**
@@ -2681,7 +2770,7 @@ class ListIndex {
2681
2770
  else {
2682
2771
  if (typeof this._indexes === "undefined" || this.dirty) {
2683
2772
  this._indexes = [...this.parentListIndex.indexes, this._index];
2684
- this._version = version;
2773
+ this._version = version$1;
2685
2774
  }
2686
2775
  }
2687
2776
  return this._indexes;
@@ -2788,6 +2877,20 @@ function isSameList(oldList, newList) {
2788
2877
  }
2789
2878
  return true;
2790
2879
  }
2880
+ /**
2881
+ * Aligns each list index's .index with its position in the new list.
2882
+ * A diff only becomes the rendered state once the updater applies it: an
2883
+ * earlier diff in the same batch (two replacements in one microtask) may have
2884
+ * moved shared indexes toward a list that never got applied, and a cache hit
2885
+ * skips recomputation entirely — so every createListDiff return re-aligns.
2886
+ */
2887
+ function syncListIndexes(newIndexes) {
2888
+ for (let i = 0; i < newIndexes.length; i++) {
2889
+ if (newIndexes[i].index !== i) {
2890
+ newIndexes[i].index = i;
2891
+ }
2892
+ }
2893
+ }
2791
2894
  /**
2792
2895
  * Creates or updates list indexes by comparing old and new lists.
2793
2896
  * Optimizes by reusing existing list indexes when values match.
@@ -2798,6 +2901,11 @@ function isSameList(oldList, newList) {
2798
2901
  * @returns Array of list indexes for the new list
2799
2902
  */
2800
2903
  function createListDiff(parentListIndex, rawOldList, rawNewList) {
2904
+ const diff = computeListDiff(parentListIndex, rawOldList, rawNewList);
2905
+ syncListIndexes(diff.newIndexes);
2906
+ return diff;
2907
+ }
2908
+ function computeListDiff(parentListIndex, rawOldList, rawNewList) {
2801
2909
  // Normalize inputs to arrays (handles null/undefined)
2802
2910
  const oldList = (Array.isArray(rawOldList) && rawOldList.length > 0) ? rawOldList : EMPTY_LIST;
2803
2911
  const newList = (Array.isArray(rawNewList) && rawNewList.length > 0) ? rawNewList : EMPTY_LIST;
@@ -2846,6 +2954,10 @@ function createListDiff(parentListIndex, rawOldList, rawNewList) {
2846
2954
  addIndexSet: EMPTY_SET$1,
2847
2955
  };
2848
2956
  }
2957
+ if (newIndexes !== null) {
2958
+ return calcDiffIndexes(oldIndexes, newIndexes);
2959
+ }
2960
+ newIndexes = [];
2849
2961
  // Use index-based map for efficiency
2850
2962
  // Supports duplicate values by storing array of indexes
2851
2963
  const indexByValue = new Map();
@@ -2858,10 +2970,6 @@ function createListDiff(parentListIndex, rawOldList, rawNewList) {
2858
2970
  }
2859
2971
  indexes.push(i);
2860
2972
  }
2861
- if (newIndexes !== null) {
2862
- return calcDiffIndexes(oldList, newList, oldIndexes, newIndexes, indexByValue);
2863
- }
2864
- newIndexes = [];
2865
2973
  // Build new indexes array by matching values with old list
2866
2974
  const changeIndexSet = new Set();
2867
2975
  const addIndexSet = new Set();
@@ -2878,9 +2986,11 @@ function createListDiff(parentListIndex, rawOldList, rawNewList) {
2878
2986
  else {
2879
2987
  // Reuse existing element
2880
2988
  const existingListIndex = oldIndexes[oldIndex];
2881
- // Update index if position changed
2882
- if (existingListIndex.index !== i) {
2883
- existingListIndex.index = i;
2989
+ // Judge position change against the old list's order (oldIndexes array
2990
+ // order), not the mutable .index an earlier diff in the same batch
2991
+ // may have already moved .index toward a list that was never applied.
2992
+ // The .index itself is re-aligned by syncListIndexes on return.
2993
+ if (oldIndex !== i) {
2884
2994
  changeIndexSet.add(existingListIndex);
2885
2995
  }
2886
2996
  newIndexes.push(existingListIndex);
@@ -2902,22 +3012,35 @@ function createListDiff(parentListIndex, rawOldList, rawNewList) {
2902
3012
  }
2903
3013
  }
2904
3014
  }
2905
- function calcDiffIndexes(oldList, newList, oldIndexes, newIndexes, indexByValue) {
3015
+ /**
3016
+ * Diff between two lists whose listIndex ledgers both already exist.
3017
+ * Rows are joined by listIndex identity — the same key applyChangeToFor and
3018
+ * walkDependency consume the result sets with. A value-based join could mark
3019
+ * oldIndexes-side objects that are absent from newIndexes (ledgers built along
3020
+ * unconnected diff chains hold different objects for the same value); such
3021
+ * orphan markers never match the consumers' has() lookups and only pollute
3022
+ * the dirty set. Rows without shared identity are represented as add+delete.
3023
+ */
3024
+ function calcDiffIndexes(oldIndexes, newIndexes) {
2906
3025
  const newIndexSet = new Set(newIndexes);
2907
3026
  const oldIndexSet = new Set(oldIndexes);
2908
3027
  const changeIndexSet = new Set();
2909
3028
  const addIndexSet = newIndexSet.difference(oldIndexSet);
2910
3029
  const deleteIndexSet = oldIndexSet.difference(newIndexSet);
2911
- for (let i = 0; i < newList.length; i++) {
2912
- const newValue = newList[i];
2913
- const existingIndexes = indexByValue.get(newValue);
2914
- const oldIndex = existingIndexes && existingIndexes.length > 0 ? existingIndexes.shift() : undefined;
2915
- if (typeof oldIndex !== "undefined") {
2916
- const existingListIndex = oldIndexes[oldIndex];
2917
- if (existingListIndex.index !== i) {
2918
- // 位置が違うことだけを記録
2919
- changeIndexSet.add(existingListIndex);
2920
- }
3030
+ // Old positions come from the oldIndexes array order (.index may have been
3031
+ // mutated by an unapplied diff in the same batch).
3032
+ const oldPosByIndex = new Map();
3033
+ for (let i = 0; i < oldIndexes.length; i++) {
3034
+ oldPosByIndex.set(oldIndexes[i], i);
3035
+ }
3036
+ for (let i = 0; i < newIndexes.length; i++) {
3037
+ const index = newIndexes[i];
3038
+ if (addIndexSet.has(index)) {
3039
+ continue;
3040
+ }
3041
+ if (oldPosByIndex.get(index) !== i) {
3042
+ // 位置が違うことだけを記録
3043
+ changeIndexSet.add(index);
2921
3044
  }
2922
3045
  }
2923
3046
  return {
@@ -3047,12 +3170,18 @@ function peekBindingSetByAbsoluteStateAddress(absoluteStateAddress) {
3047
3170
  function addBindingByAbsoluteStateAddress(absoluteStateAddress, binding) {
3048
3171
  const bindingSet = getBindingSetByAbsoluteStateAddress(absoluteStateAddress);
3049
3172
  bindingSet.add(binding);
3173
+ if (devtoolsSink !== null) {
3174
+ devtoolsSink({ type: "state:binding-added", absoluteAddress: absoluteStateAddress, binding });
3175
+ }
3050
3176
  }
3051
3177
  function removeBindingByAbsoluteStateAddress(absoluteStateAddress, binding) {
3052
3178
  // get-or-create を通すと未登録アドレスに空 Set を生成してしまうため素の get で参照する
3053
3179
  const bindingSet = bindingSetByAbsoluteStateAddress.get(absoluteStateAddress);
3054
3180
  if (bindingSet !== undefined) {
3055
3181
  bindingSet.delete(binding);
3182
+ if (devtoolsSink !== null) {
3183
+ devtoolsSink({ type: "state:binding-removed", absoluteAddress: absoluteStateAddress, binding });
3184
+ }
3056
3185
  }
3057
3186
  }
3058
3187
 
@@ -4744,840 +4873,1133 @@ async function buildBindings(root) {
4744
4873
  }
4745
4874
  }
4746
4875
 
4747
- // ハイドレーション時にスキップするバインディングタイプ
4748
- const STRUCTURAL_TYPES = new Set(['for', 'if', 'elseif', 'else']);
4876
+ var version = "1.20.0";
4877
+ var pkg = {
4878
+ version: version};
4879
+
4880
+ const VERSION = pkg.version;
4881
+
4882
+ // SSR コメントパターン
4883
+ const SSR_PLACEHOLDER_COMMENT = /^@@wcs-(?:for|if|elseif|else):[^-]/;
4884
+ const SSR_BLOCK_START = /^@@wcs-(for|if|elseif|else)-start:(.+)$/;
4885
+ const SSR_BLOCK_END = /^@@wcs-(for|if|elseif|else)-end:(.+)$/;
4886
+ const SSR_TEXT_START = /^@@wcs-text-start:(.+)$/;
4749
4887
  /**
4750
- * SSR ブロック境界コメントを走査して、start〜end 間のノードを収集する
4888
+ * script 要素へ埋め込む JSON を HTML パーサから保護する。
4889
+ * HTML 直列化時、script の中身は生のまま出力されるため、state 値に
4890
+ * "</script>" や "<!--" を含む文字列があると script を脱出できてしまう。
4891
+ * "<" ">" "&" と U+2028/U+2029 を JSON の \uXXXX エスケープへ置換する
4892
+ * (JSON.parse では元の文字列と等価に復元される)。
4751
4893
  */
4752
- function collectSsrBlocks(root) {
4753
- const blocks = [];
4754
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
4755
- const startComments = [];
4756
- // まず全コメントを収集
4757
- while (walker.nextNode()) {
4758
- startComments.push(walker.currentNode);
4894
+ function escapeJsonForScript(json) {
4895
+ return json
4896
+ .replace(/</g, '\\u003c')
4897
+ .replace(/>/g, '\\u003e')
4898
+ .replace(/&/g, '\\u0026')
4899
+ .replace(/\u2028/g, '\\u2028')
4900
+ .replace(/\u2029/g, '\\u2029');
4901
+ }
4902
+ class Ssr extends HTMLElement {
4903
+ _stateData = null;
4904
+ _templates = null;
4905
+ _hydrateProps = null;
4906
+ get name() {
4907
+ return this.getAttribute('name') || 'default';
4759
4908
  }
4760
- for (const comment of startComments) {
4761
- const startMatch = SSR_BLOCK_START.exec(comment.data);
4762
- if (!startMatch)
4763
- continue;
4764
- const type = startMatch[1];
4765
- const info = startMatch[2]; // "uuid:path:index" or "uuid:path"
4766
- const parts = info.split(':');
4767
- let uuid;
4768
- let path;
4769
- let index = null;
4770
- if (type === 'for') {
4771
- // uuid:path:index
4772
- uuid = parts[0];
4773
- path = parts[1];
4774
- index = parseInt(parts[2], 10);
4909
+ get version() {
4910
+ return this.getAttribute('version') || '';
4911
+ }
4912
+ get stateData() {
4913
+ if (this._stateData === null) {
4914
+ this._stateData = this._loadStateData();
4775
4915
  }
4776
- else {
4777
- // uuid:path
4778
- uuid = parts[0];
4779
- path = parts.slice(1).join(':');
4916
+ return this._stateData;
4917
+ }
4918
+ get templates() {
4919
+ if (this._templates === null) {
4920
+ this._templates = this._loadTemplates();
4780
4921
  }
4781
- // start と end の間のノードを収集
4782
- const nodes = [];
4783
- let sibling = comment.nextSibling;
4784
- const endPattern = `@@wcs-${type}-end:${info}`;
4785
- while (sibling) {
4786
- if (sibling.nodeType === Node.COMMENT_NODE && sibling.data === endPattern) {
4787
- break;
4788
- }
4789
- nodes.push(sibling);
4790
- sibling = sibling.nextSibling;
4922
+ return this._templates;
4923
+ }
4924
+ get hydrateProps() {
4925
+ if (this._hydrateProps === null) {
4926
+ this._hydrateProps = this._loadHydrateProps();
4791
4927
  }
4792
- blocks.push({ type, uuid, path, index, nodes });
4928
+ return this._hydrateProps;
4793
4929
  }
4794
- return blocks;
4795
- }
4796
- /**
4797
- * live DOM ノード群からバインディングを収集する。
4798
- * ノードを一時的に DocumentFragment に移動して collectNodesAndBindingInfos を実行し、
4799
- * 元の位置に戻す。
4800
- */
4801
- function collectBindingsFromLiveNodes(nodes) {
4802
- if (nodes.length === 0)
4803
- return { bindingInfos: [], subscriberNodes: [] };
4804
- // ノードの元の位置を記録
4805
- const parent = nodes[0].parentNode;
4806
- const nextSibling = nodes[nodes.length - 1].nextSibling;
4807
- // 一時的に wrapper 要素に移動(collectNodesAndBindingInfos は Element を受け付ける)
4808
- const wrapper = document.createElement('div');
4809
- for (const node of nodes) {
4810
- wrapper.appendChild(node);
4930
+ getTemplate(uuid) {
4931
+ return this.templates.get(uuid) ?? null;
4811
4932
  }
4812
- // バインディング収集
4813
- const [subscriberNodes, allBindings] = collectNodesAndBindingInfos(wrapper);
4814
- // _initializeBindings 相当の処理
4815
- for (const binding of allBindings) {
4816
- replaceToReplaceNode(binding);
4817
- if (attachEventHandler(binding))
4818
- continue;
4819
- if (attachEventTokenHandler(binding))
4820
- continue;
4821
- attachTwowayEventHandler(binding);
4822
- attachRadioEventHandler(binding);
4823
- attachCheckboxEventHandler(binding);
4933
+ /**
4934
+ * サーバーの SSR バージョンとクライアントの state バージョンを検証する。
4935
+ * メジャー・マイナーバージョンが一致すればtrue。
4936
+ * version 属性がない場合は検証スキップ(true)。
4937
+ */
4938
+ verifyVersion() {
4939
+ const serverVersion = this.version;
4940
+ if (!serverVersion)
4941
+ return true;
4942
+ const serverParts = serverVersion.split('.');
4943
+ const clientParts = VERSION.split('.');
4944
+ // メジャー・マイナーが一致すれば互換
4945
+ return serverParts[0] === clientParts[0] && serverParts[1] === clientParts[1];
4824
4946
  }
4825
- // 元の位置に戻す
4826
- if (parent) {
4827
- while (wrapper.firstChild) {
4828
- parent.insertBefore(wrapper.firstChild, nextSibling);
4829
- }
4947
+ setStateData(data) {
4948
+ this._stateData = data;
4830
4949
  }
4831
- return {
4832
- bindingInfos: allBindings,
4833
- subscriberNodes,
4834
- };
4835
- }
4836
- /**
4837
- * SSR ブロックの DOM ノードを Content 化し、バインディングを登録する。
4838
- */
4839
- function hydrateBlocks(root, blocks) {
4840
- // for ブロックの listIndex を UUID ごとに収集
4841
- const listIndexesByUuid = new Map();
4842
- for (const block of blocks) {
4843
- if (block.nodes.length === 0)
4844
- continue;
4845
- const content = createContentFromNodes(block.nodes);
4846
- // Content のバインディングを収集
4847
- const { bindingInfos, subscriberNodes } = collectBindingsFromLiveNodes(block.nodes);
4848
- // Content 内のノードに data-wcs-completed を付与
4849
- // (メインの collectNodesAndBindingInfos で重複登録されないようにする)
4850
- for (const node of subscriberNodes) {
4851
- if (node.nodeType === Node.ELEMENT_NODE) {
4852
- node.setAttribute('data-wcs-completed', '');
4853
- }
4950
+ setHydrateProps(props) {
4951
+ this._hydrateProps = props;
4952
+ }
4953
+ _loadStateData() {
4954
+ const script = this.querySelector(`script[type="application/json"]:not([data-wcs-ssr-props])`);
4955
+ if (!script)
4956
+ return {};
4957
+ try {
4958
+ return JSON.parse(script.textContent || '{}');
4854
4959
  }
4855
- setBindingsByContent(content, bindingInfos);
4856
- setNodesByContent(content, subscriberNodes);
4857
- const indexBindings = [];
4858
- for (const binding of bindingInfos) {
4859
- if (binding.statePathName in INDEX_BY_INDEX_NAME) {
4860
- indexBindings.push(binding);
4960
+ catch {
4961
+ return {};
4962
+ }
4963
+ }
4964
+ _loadTemplates() {
4965
+ const map = new Map();
4966
+ const templates = this.querySelectorAll('template[id]');
4967
+ for (const tpl of templates) {
4968
+ const id = tpl.getAttribute('id');
4969
+ if (id) {
4970
+ map.set(id, tpl);
4861
4971
  }
4862
4972
  }
4863
- setIndexBindingsByContent(content, indexBindings);
4864
- if (block.type === 'for' && block.index !== null) {
4865
- const placeholderComment = findPlaceholderComment(root, 'for', block.uuid);
4866
- if (placeholderComment) {
4867
- const listIndex = createListIndex(null, block.index);
4868
- hydrateSetContent(placeholderComment, listIndex, content);
4869
- const lastNode = block.nodes[block.nodes.length - 1];
4870
- hydrateSetLastNode(placeholderComment, lastNode);
4871
- setContentByNode(placeholderComment, content);
4872
- // ループコンテキストをバインドし、バインディングをアドレスに登録
4873
- const pathInfo = getPathInfo(block.path + '.' + WILDCARD);
4874
- const stateAddress = createStateAddress(pathInfo, listIndex);
4875
- // ILoopContext は IStateAddress + listIndex なので、stateAddress をそのまま使う
4876
- bindLoopContextToContent(content, stateAddress);
4877
- for (const binding of bindingInfos) {
4878
- const absAddr = getAbsoluteStateAddressByBinding(binding);
4879
- addBindingByAbsoluteStateAddress(absAddr, binding);
4880
- }
4881
- // listIndex を UUID ごとに収集(後で setListIndexesByList に渡す)
4882
- let indexes = listIndexesByUuid.get(block.uuid);
4883
- if (!indexes) {
4884
- indexes = [];
4885
- listIndexesByUuid.set(block.uuid, indexes);
4886
- }
4887
- indexes.push(listIndex);
4973
+ return map;
4974
+ }
4975
+ _loadHydrateProps() {
4976
+ const script = this.querySelector('script[data-wcs-ssr-props]');
4977
+ if (!script)
4978
+ return {};
4979
+ try {
4980
+ return JSON.parse(script.textContent || '{}');
4981
+ }
4982
+ catch {
4983
+ return {};
4984
+ }
4985
+ }
4986
+ static findByName(root, name) {
4987
+ const tagName = config.tagNames.ssr;
4988
+ const parentEl = root instanceof Element
4989
+ ? root
4990
+ : root instanceof Document
4991
+ ? root.documentElement
4992
+ : null;
4993
+ if (!parentEl)
4994
+ return null;
4995
+ const el = parentEl.querySelector(`${tagName}[name="${name}"]`);
4996
+ return el;
4997
+ }
4998
+ /**
4999
+ * stateData と構造テンプレート・プロパティから <wcs-ssr> の中身を構築する。
5000
+ * server パッケージの renderToString から呼ばれる。
5001
+ */
5002
+ /**
5003
+ * wcs-state 要素から $ プレフィックスや関数を除いたデータを抽出する。
5004
+ */
5005
+ static extractStateData(stateEl) {
5006
+ const raw = stateEl.__state;
5007
+ if (!raw || typeof raw !== 'object')
5008
+ return {};
5009
+ const data = {};
5010
+ for (const [key, value] of Object.entries(raw)) {
5011
+ if (!key.startsWith('$') && typeof value !== 'function') {
5012
+ data[key] = value;
4888
5013
  }
4889
5014
  }
4890
- else {
4891
- const placeholderComment = findPlaceholderComment(root, block.type, block.uuid);
4892
- if (placeholderComment) {
4893
- setContentByNode(placeholderComment, content);
4894
- // バインディングをアドレスに登録
4895
- for (const binding of bindingInfos) {
4896
- const absAddr = getAbsoluteStateAddressByBinding(binding);
4897
- addBindingByAbsoluteStateAddress(absAddr, binding);
5015
+ return data;
5016
+ }
5017
+ static buildContent(ssrEl, stateData) {
5018
+ // 初期データ JSON
5019
+ const jsonScript = document.createElement('script');
5020
+ jsonScript.setAttribute('type', 'application/json');
5021
+ jsonScript.textContent = escapeJsonForScript(JSON.stringify(stateData));
5022
+ ssrEl.appendChild(jsonScript);
5023
+ // UUID で管理されているテンプレートを復元して格納
5024
+ const uuids = getAllFragmentUUIDs();
5025
+ for (const uuid of uuids) {
5026
+ const fragmentInfo = getFragmentInfoByUUID(uuid);
5027
+ if (!fragmentInfo)
5028
+ continue;
5029
+ const tpl = document.createElement('template');
5030
+ tpl.setAttribute('id', uuid);
5031
+ const bindResult = fragmentInfo.parseBindTextResult;
5032
+ const bindText = bindResult.bindingType === 'else'
5033
+ ? 'else:'
5034
+ : `${bindResult.bindingType}: ${bindResult.statePathName}`;
5035
+ tpl.setAttribute(config.bindAttributeName, bindText);
5036
+ const content = fragmentInfo.fragment.cloneNode(true);
5037
+ tpl.content.appendChild(content);
5038
+ ssrEl.appendChild(tpl);
5039
+ }
5040
+ // 属性で代替不可なプロパティをハイドレーション用に格納
5041
+ const ssrNodes = getAllSsrPropertyNodes();
5042
+ if (ssrNodes.length > 0) {
5043
+ const propsData = {};
5044
+ for (let i = 0; i < ssrNodes.length; i++) {
5045
+ const node = ssrNodes[i];
5046
+ const entries = getSsrProperties(node);
5047
+ if (entries.length === 0)
5048
+ continue;
5049
+ const id = `wcs-ssr-${i}`;
5050
+ node.setAttribute('data-wcs-ssr-id', id);
5051
+ const props = {};
5052
+ for (const entry of entries) {
5053
+ props[entry.propName] = entry.value;
4898
5054
  }
5055
+ propsData[id] = props;
5056
+ }
5057
+ if (Object.keys(propsData).length > 0) {
5058
+ const propsScript = document.createElement('script');
5059
+ propsScript.setAttribute('type', 'application/json');
5060
+ propsScript.setAttribute('data-wcs-ssr-props', '');
5061
+ propsScript.textContent = escapeJsonForScript(JSON.stringify(propsData));
5062
+ ssrEl.appendChild(propsScript);
4899
5063
  }
4900
5064
  }
5065
+ clearSsrPropertyStore();
4901
5066
  }
4902
- // for ブロックの listIndex を state のリスト値に紐づける
4903
- for (const [uuid, indexes] of listIndexesByUuid) {
4904
- const placeholderComment = findPlaceholderComment(root, 'for', uuid);
4905
- if (!placeholderComment)
4906
- continue;
4907
- // state から現在のリスト値を取得して listIndexes を設定
4908
- const rootNode = placeholderComment.getRootNode();
4909
- // structuralBindings はまだ登録前なので、getParseBindTextResults を直接使う
4910
- const fragmentInfo = getFragmentInfoByUUID(uuid);
4911
- if (!fragmentInfo)
4912
- continue;
4913
- const stateName = fragmentInfo.parseBindTextResult.stateName;
4914
- const statePathName = fragmentInfo.parseBindTextResult.statePathName;
4915
- const stateElement = getStateElementByName(rootNode, stateName);
4916
- if (!stateElement)
4917
- continue;
4918
- stateElement.createState("readonly", (state) => {
4919
- const list = state[statePathName];
4920
- if (Array.isArray(list)) {
4921
- setListIndexesByList(list, indexes);
5067
+ /**
5068
+ * SSR ブロック境界コメント (@@wcs-*-start/end) を除去する
5069
+ */
5070
+ static removeBlockBoundaryComments(root) {
5071
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
5072
+ const toRemove = [];
5073
+ while (walker.nextNode()) {
5074
+ const comment = walker.currentNode;
5075
+ if (SSR_BLOCK_START.test(comment.data) || SSR_BLOCK_END.test(comment.data)) {
5076
+ toRemove.push(comment);
4922
5077
  }
4923
- });
5078
+ }
5079
+ for (const comment of toRemove) {
5080
+ comment.remove();
5081
+ }
4924
5082
  }
4925
- }
4926
- function findPlaceholderComment(root, type, uuid) {
4927
- const keywordMap = {
4928
- 'for': config.commentForPrefix,
4929
- 'if': config.commentIfPrefix,
4930
- 'elseif': config.commentElseIfPrefix,
4931
- 'else': config.commentElsePrefix,
4932
- };
4933
- const keyword = keywordMap[type];
4934
- if (!keyword)
4935
- return null;
4936
- const pattern = `@@${keyword}:${uuid}`;
4937
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
4938
- while (walker.nextNode()) {
4939
- const comment = walker.currentNode;
4940
- if (comment.data === pattern) {
4941
- return comment;
5083
+ /**
5084
+ * SSR の構造プレースホルダーコメント (@@wcs-for:uuid) を除去する
5085
+ */
5086
+ static removeStructuralComments(root) {
5087
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
5088
+ const toRemove = [];
5089
+ while (walker.nextNode()) {
5090
+ const comment = walker.currentNode;
5091
+ if (SSR_PLACEHOLDER_COMMENT.test(comment.data)) {
5092
+ toRemove.push(comment);
5093
+ }
5094
+ }
5095
+ for (const comment of toRemove) {
5096
+ comment.remove();
4942
5097
  }
4943
5098
  }
4944
- return null;
4945
- }
4946
- /**
4947
- * <wcs-ssr> 内のテンプレートを fragmentInfoByUUID に復帰させる。
4948
- */
4949
- function restoreFragments(root, ssrEl) {
4950
- const rootNode = root;
4951
- let lastIfParseResult = null;
4952
- for (const [uuid, tpl] of ssrEl.templates) {
4953
- const bindText = tpl.getAttribute(config.bindAttributeName) || '';
4954
- const parseBindTextResults = parseBindTextsForElement(bindText);
4955
- let parseBindTextResult = parseBindTextResults[0];
4956
- const bindingType = parseBindTextResult.bindingType;
4957
- // else: 直前の if 条件の not → 条件反転
4958
- // elseif: 独自条件を持つが stateName は if から引き継ぐ
4959
- if (bindingType === 'else' && lastIfParseResult) {
4960
- parseBindTextResult = {
4961
- ...lastIfParseResult,
4962
- outFilters: [...lastIfParseResult.outFilters, createNotFilter()],
4963
- bindingType: 'else',
4964
- };
5099
+ /**
5100
+ * SSR テキストバインディングコメントを復元する。
5101
+ * <!--@@wcs-text-start:path-->text<!--@@wcs-text-end:path-->
5102
+ * <!--@@: path--> (バインディングシステムが認識する形式)
5103
+ */
5104
+ static restoreTextBindings(root) {
5105
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
5106
+ const startComments = [];
5107
+ while (walker.nextNode()) {
5108
+ const comment = walker.currentNode;
5109
+ const match = SSR_TEXT_START.exec(comment.data);
5110
+ if (match) {
5111
+ startComments.push({ comment, path: match[1] });
5112
+ }
4965
5113
  }
4966
- else if (bindingType === 'elseif' && lastIfParseResult) {
4967
- parseBindTextResult = {
4968
- ...parseBindTextResult,
4969
- stateName: lastIfParseResult.stateName,
4970
- };
5114
+ for (const { comment, path } of startComments) {
5115
+ const bindComment = document.createComment(`@@: ${path}`);
5116
+ comment.parentNode.insertBefore(bindComment, comment);
5117
+ let sibling = comment.nextSibling;
5118
+ comment.remove();
5119
+ const endPattern = `@@wcs-text-end:${path}`;
5120
+ while (sibling) {
5121
+ const next = sibling.nextSibling;
5122
+ if (sibling.nodeType === Node.COMMENT_NODE && sibling.data === endPattern) {
5123
+ sibling.parentNode.removeChild(sibling);
5124
+ break;
5125
+ }
5126
+ sibling.parentNode.removeChild(sibling);
5127
+ sibling = next;
5128
+ }
4971
5129
  }
4972
- // if chain の追跡
4973
- if (bindingType === 'if') {
4974
- lastIfParseResult = parseBindTextResult;
5130
+ }
5131
+ /**
5132
+ * SSR DOM をクリーンアップし、buildBindings が動作できる状態に戻す。
5133
+ * バージョン不一致時のフォールバック用。
5134
+ *
5135
+ * 1. SSR ブロック境界コメント間のレンダリング済みノードを除去
5136
+ * 2. SSR テキストバインディングを @@: 形式に復元
5137
+ * 3. プレースホルダーコメントを <wcs-ssr> 内のテンプレートで差し替え
5138
+ * 4. data-wcs-ssr-id 属性を除去
5139
+ * 5. <wcs-ssr> を除去
5140
+ */
5141
+ static cleanupDom(root) {
5142
+ const body = document.body;
5143
+ // <wcs-ssr> からテンプレート UUID マップを構築(カスタム要素未定義でも動作するよう DOM 直接走査)
5144
+ const ssrElements = root.querySelectorAll(config.tagNames.ssr);
5145
+ const templateByUuid = new Map();
5146
+ for (const ssrNode of ssrElements) {
5147
+ const templates = ssrNode.querySelectorAll('template[id]');
5148
+ for (const tpl of templates) {
5149
+ const id = tpl.getAttribute('id');
5150
+ if (id) {
5151
+ templateByUuid.set(id, tpl);
5152
+ }
5153
+ }
4975
5154
  }
4976
- else if (bindingType === 'elseif') {
4977
- lastIfParseResult = parseBindTextResult;
5155
+ // SSR ブロック境界コメント間のレンダリング済みノードと境界コメントを除去
5156
+ const walker1 = document.createTreeWalker(body, NodeFilter.SHOW_COMMENT);
5157
+ const startComments = [];
5158
+ while (walker1.nextNode()) {
5159
+ const comment = walker1.currentNode;
5160
+ if (SSR_BLOCK_START.test(comment.data)) {
5161
+ startComments.push(comment);
5162
+ }
4978
5163
  }
4979
- else if (bindingType === 'else') {
4980
- lastIfParseResult = null;
5164
+ for (const startComment of startComments) {
5165
+ const match = SSR_BLOCK_START.exec(startComment.data);
5166
+ const type = match[1];
5167
+ const info = match[2];
5168
+ const endPattern = `@@wcs-${type}-end:${info}`;
5169
+ let sibling = startComment.nextSibling;
5170
+ while (sibling) {
5171
+ const next = sibling.nextSibling;
5172
+ if (sibling.nodeType === Node.COMMENT_NODE && sibling.data === endPattern) {
5173
+ sibling.remove();
5174
+ break;
5175
+ }
5176
+ sibling.remove();
5177
+ sibling = next;
5178
+ }
5179
+ startComment.remove();
4981
5180
  }
4982
- const fragment = document.importNode(tpl.content, true);
4983
- const forPath = bindingType === "for" ? parseBindTextResult.statePathName : undefined;
4984
- optimizeFragment(fragment);
4985
- if (typeof forPath === "string") {
4986
- expandShorthandPaths(fragment, forPath);
5181
+ // SSR テキストバインディングを @@: 形式に復元
5182
+ Ssr.restoreTextBindings(body);
5183
+ // プレースホルダーコメント (@@wcs-for:uuid 等) をテンプレートに差し替え
5184
+ const walker2 = document.createTreeWalker(body, NodeFilter.SHOW_COMMENT);
5185
+ const placeholders = [];
5186
+ while (walker2.nextNode()) {
5187
+ const comment = walker2.currentNode;
5188
+ if (SSR_PLACEHOLDER_COMMENT.test(comment.data)) {
5189
+ const uuid = comment.data.split(':')[1];
5190
+ placeholders.push({ comment, uuid });
5191
+ }
5192
+ }
5193
+ for (const { comment, uuid } of placeholders) {
5194
+ const tpl = templateByUuid.get(uuid);
5195
+ if (tpl) {
5196
+ const restored = document.createElement('template');
5197
+ const bindAttr = tpl.getAttribute(config.bindAttributeName);
5198
+ if (bindAttr)
5199
+ restored.setAttribute(config.bindAttributeName, bindAttr);
5200
+ const imported = document.importNode(tpl.content, true);
5201
+ if (imported.childNodes.length > 0) {
5202
+ restored.content.appendChild(imported);
5203
+ }
5204
+ else {
5205
+ for (const child of Array.from(tpl.childNodes)) {
5206
+ restored.content.appendChild(document.importNode(child, true));
5207
+ }
5208
+ }
5209
+ comment.parentNode.replaceChild(restored, comment);
5210
+ }
5211
+ }
5212
+ // data-wcs-ssr-id 属性を除去
5213
+ const ssrIdElements = root.querySelectorAll('[data-wcs-ssr-id]');
5214
+ for (const el of ssrIdElements) {
5215
+ el.removeAttribute('data-wcs-ssr-id');
5216
+ }
5217
+ // <wcs-ssr> を除去
5218
+ for (const el of ssrElements) {
5219
+ el.remove();
4987
5220
  }
4988
- collectStructuralFragments(rootNode, fragment, forPath);
4989
- const fragmentInfo = {
4990
- fragment,
4991
- parseBindTextResult,
4992
- nodeInfos: getFragmentNodeInfos(fragment),
4993
- };
4994
- setFragmentInfoByUUID(uuid, rootNode, fragmentInfo);
4995
5221
  }
4996
5222
  }
5223
+
5224
+ // ハイドレーション時にスキップするバインディングタイプ
5225
+ const STRUCTURAL_TYPES = new Set(['for', 'if', 'elseif', 'else']);
4997
5226
  /**
4998
- * SSR ハイドレーション用バインディング初期化。
4999
- * バージョン不一致時は DOM をクリーンアップして false を返す
5000
- * (呼び出し元で buildBindings にフォールバック)。
5227
+ * SSR ブロック境界コメントを走査して、start〜end 間のノードを収集する
5001
5228
  */
5002
- async function hydrateBindings(root) {
5003
- await waitForStateInitialize(root);
5004
- // バージョン検証
5005
- const ssrElements = root.querySelectorAll(config.tagNames.ssr);
5006
- for (const ssrNode of ssrElements) {
5007
- const ssrEl = ssrNode;
5008
- if (!ssrEl.verifyVersion()) {
5009
- console.warn(`[@wcstack/state] SSR version mismatch: server="${ssrEl.version}", client="${VERSION}". Falling back to full render.`);
5010
- Ssr.cleanupDom(root);
5011
- return false;
5012
- }
5013
- }
5014
- // <wcs-ssr> からテンプレートを fragmentInfoByUUID に復帰
5015
- for (const ssrNode of ssrElements) {
5016
- restoreFragments(root, ssrNode);
5017
- }
5018
- // SSR ブロック境界コメントから既存 DOM を Content 化
5019
- const blocks = collectSsrBlocks(document.body);
5020
- hydrateBlocks(document.body, blocks);
5021
- // ブロック境界コメント (start/end) を除去
5022
- Ssr.removeBlockBoundaryComments(document.body);
5023
- // <wcs-ssr> を一時除去(バインディング走査に含めない)
5024
- const ssrParents = [];
5025
- for (const el of ssrElements) {
5026
- if (el.parentNode) {
5027
- ssrParents.push({ el, parent: el.parentNode, next: el.nextSibling });
5028
- el.remove();
5029
- }
5229
+ function collectSsrBlocks(root) {
5230
+ const blocks = [];
5231
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
5232
+ const startComments = [];
5233
+ // まず全コメントを収集
5234
+ while (walker.nextNode()) {
5235
+ startComments.push(walker.currentNode);
5030
5236
  }
5031
- // 構造プレースホルダーコメント (@@wcs-for:uuid ) は残す
5032
- // バインディング走査で拾われ、状態変化時の再レンダリングに使われる
5033
- // SSR テキストバインディングを @@: 形式に復元
5034
- Ssr.restoreTextBindings(document.body);
5035
- // ノードとバインディングを収集
5036
- const [subscriberNodes, allBindings] = collectNodesAndBindingInfos(document.body);
5037
- // 収集完了したノードに data-wcs-completed 属性を付与
5038
- // for ブロック内ノード(hydrateBlocks で登録済み)にはループコンテキストをリセットしない
5039
- for (const node of subscriberNodes) {
5040
- if (node.nodeType === Node.ELEMENT_NODE) {
5041
- const el = node;
5042
- if (!el.hasAttribute('data-wcs-completed')) {
5043
- setLoopContextByNode(node, null);
5044
- el.setAttribute('data-wcs-completed', '');
5045
- }
5237
+ for (const comment of startComments) {
5238
+ const startMatch = SSR_BLOCK_START.exec(comment.data);
5239
+ if (!startMatch)
5240
+ continue;
5241
+ const type = startMatch[1];
5242
+ const info = startMatch[2]; // "uuid:path:index" or "uuid:path"
5243
+ const parts = info.split(':');
5244
+ let uuid;
5245
+ let path;
5246
+ let index = null;
5247
+ if (type === 'for') {
5248
+ // uuid:path:index
5249
+ uuid = parts[0];
5250
+ path = parts[1];
5251
+ index = parseInt(parts[2], 10);
5046
5252
  }
5047
5253
  else {
5048
- // コメントノード等
5049
- setLoopContextByNode(node, null);
5254
+ // uuid:path
5255
+ uuid = parts[0];
5256
+ path = parts.slice(1).join(':');
5257
+ }
5258
+ // start と end の間のノードを収集
5259
+ const nodes = [];
5260
+ let sibling = comment.nextSibling;
5261
+ const endPattern = `@@wcs-${type}-end:${info}`;
5262
+ while (sibling) {
5263
+ if (sibling.nodeType === Node.COMMENT_NODE && sibling.data === endPattern) {
5264
+ break;
5265
+ }
5266
+ nodes.push(sibling);
5267
+ sibling = sibling.nextSibling;
5050
5268
  }
5269
+ blocks.push({ type, uuid, path, index, nodes });
5270
+ }
5271
+ return blocks;
5272
+ }
5273
+ /**
5274
+ * live DOM ノード群からバインディングを収集する。
5275
+ * ノードを一時的に DocumentFragment に移動して collectNodesAndBindingInfos を実行し、
5276
+ * 元の位置に戻す。
5277
+ */
5278
+ function collectBindingsFromLiveNodes(nodes) {
5279
+ if (nodes.length === 0)
5280
+ return { bindingInfos: [], subscriberNodes: [] };
5281
+ // ノードの元の位置を記録
5282
+ const parent = nodes[0].parentNode;
5283
+ const nextSibling = nodes[nodes.length - 1].nextSibling;
5284
+ // 一時的に wrapper 要素に移動(collectNodesAndBindingInfos は Element を受け付ける)
5285
+ const wrapper = document.createElement('div');
5286
+ for (const node of nodes) {
5287
+ wrapper.appendChild(node);
5051
5288
  }
5052
- // バインディングを構造系とそれ以外に分離
5053
- const normalBindings = [];
5054
- const structuralBindings = [];
5289
+ // バインディング収集
5290
+ const [subscriberNodes, allBindings] = collectNodesAndBindingInfos(wrapper);
5291
+ // _initializeBindings 相当の処理
5055
5292
  for (const binding of allBindings) {
5056
5293
  replaceToReplaceNode(binding);
5057
- if (attachEventHandler(binding)) {
5294
+ if (attachEventHandler(binding))
5058
5295
  continue;
5059
- }
5060
- if (attachEventTokenHandler(binding)) {
5296
+ if (attachEventTokenHandler(binding))
5061
5297
  continue;
5062
- }
5063
5298
  attachTwowayEventHandler(binding);
5064
5299
  attachRadioEventHandler(binding);
5065
5300
  attachCheckboxEventHandler(binding);
5066
- if (STRUCTURAL_TYPES.has(binding.bindingType)) {
5067
- structuralBindings.push(binding);
5068
- }
5069
- else if (binding.statePathName.includes(WILDCARD)) {
5070
- // for ブロック内のバインディング → Content のバインディングとして登録済み
5071
- continue;
5072
- }
5073
- else {
5074
- normalBindings.push(binding);
5301
+ }
5302
+ // 元の位置に戻す
5303
+ if (parent) {
5304
+ while (wrapper.firstChild) {
5305
+ parent.insertBefore(wrapper.firstChild, nextSibling);
5075
5306
  }
5076
5307
  }
5077
- // 全バインディング(通常 + 構造)をアドレスに登録
5078
- for (const binding of [...normalBindings, ...structuralBindings]) {
5079
- const absoluteStateAddress = getAbsoluteStateAddressByBinding(binding);
5080
- addBindingByAbsoluteStateAddress(absoluteStateAddress, binding);
5081
- const rootNode = binding.replaceNode.getRootNode();
5082
- const stateElement = getStateElementByName(rootNode, binding.stateName);
5083
- if (stateElement === null) {
5084
- raiseError(`State element with name "${binding.stateName}" not found for binding.`);
5308
+ return {
5309
+ bindingInfos: allBindings,
5310
+ subscriberNodes,
5311
+ };
5312
+ }
5313
+ /**
5314
+ * SSR ブロックの DOM ノードを Content 化し、バインディングを登録する。
5315
+ */
5316
+ function hydrateBlocks(root, blocks) {
5317
+ // for ブロックの listIndex を UUID ごとに収集
5318
+ const listIndexesByUuid = new Map();
5319
+ for (const block of blocks) {
5320
+ if (block.nodes.length === 0)
5321
+ continue;
5322
+ const content = createContentFromNodes(block.nodes);
5323
+ // Content のバインディングを収集
5324
+ const { bindingInfos, subscriberNodes } = collectBindingsFromLiveNodes(block.nodes);
5325
+ // Content 内のノードに data-wcs-completed を付与
5326
+ // (メインの collectNodesAndBindingInfos で重複登録されないようにする)
5327
+ for (const node of subscriberNodes) {
5328
+ if (node.nodeType === Node.ELEMENT_NODE) {
5329
+ node.setAttribute('data-wcs-completed', '');
5330
+ }
5085
5331
  }
5086
- if (binding.bindingType !== 'event') {
5087
- stateElement.setPathInfo(binding.statePathName, binding.bindingType);
5332
+ setBindingsByContent(content, bindingInfos);
5333
+ setNodesByContent(content, subscriberNodes);
5334
+ const indexBindings = [];
5335
+ for (const binding of bindingInfos) {
5336
+ if (binding.statePathName in INDEX_BY_INDEX_NAME) {
5337
+ indexBindings.push(binding);
5338
+ }
5088
5339
  }
5089
- }
5090
- // for バインディングの lastListValue を初期値として設定
5091
- // (次回の状態変化時に差分計算の基準になる)
5092
- for (const binding of structuralBindings) {
5093
- if (binding.bindingType === 'for') {
5094
- const absAddr = getAbsoluteStateAddressByBinding(binding);
5095
- const rootNode = binding.replaceNode.getRootNode();
5096
- const stateElement = getStateElementByName(rootNode, binding.stateName);
5097
- if (stateElement) {
5098
- stateElement.createState("readonly", (state) => {
5099
- const value = state[binding.statePathName];
5100
- if (Array.isArray(value)) {
5101
- setLastListValueByAbsoluteStateAddress(absAddr, value);
5102
- }
5103
- });
5340
+ setIndexBindingsByContent(content, indexBindings);
5341
+ if (block.type === 'for' && block.index !== null) {
5342
+ const placeholderComment = findPlaceholderComment(root, 'for', block.uuid);
5343
+ if (placeholderComment) {
5344
+ const listIndex = createListIndex(null, block.index);
5345
+ hydrateSetContent(placeholderComment, listIndex, content);
5346
+ const lastNode = block.nodes[block.nodes.length - 1];
5347
+ hydrateSetLastNode(placeholderComment, lastNode);
5348
+ setContentByNode(placeholderComment, content);
5349
+ // ループコンテキストをバインドし、バインディングをアドレスに登録
5350
+ const pathInfo = getPathInfo(block.path + '.' + WILDCARD);
5351
+ const stateAddress = createStateAddress(pathInfo, listIndex);
5352
+ // ILoopContext は IStateAddress + listIndex なので、stateAddress をそのまま使う
5353
+ bindLoopContextToContent(content, stateAddress);
5354
+ for (const binding of bindingInfos) {
5355
+ const absAddr = getAbsoluteStateAddressByBinding(binding);
5356
+ addBindingByAbsoluteStateAddress(absAddr, binding);
5357
+ }
5358
+ // listIndex を UUID ごとに収集(後で setListIndexesByList に渡す)
5359
+ let indexes = listIndexesByUuid.get(block.uuid);
5360
+ if (!indexes) {
5361
+ indexes = [];
5362
+ listIndexesByUuid.set(block.uuid, indexes);
5363
+ }
5364
+ indexes.push(listIndex);
5104
5365
  }
5105
5366
  }
5106
- }
5107
- // 通常バインディングのみ初回値適用(構造バインディングはSSR描画済み)
5108
- applyChangeFromBindings(normalBindings);
5109
- // <wcs-ssr> を元に戻す
5110
- for (const { el, parent, next } of ssrParents) {
5111
- parent.insertBefore(el, next);
5112
- }
5113
- // hydrateProps 復元
5114
- const restoredSsrElements = root.querySelectorAll(config.tagNames.ssr);
5115
- for (const ssrNode of restoredSsrElements) {
5116
- const ssrEl = ssrNode;
5117
- const props = ssrEl.hydrateProps;
5118
- for (const [id, propMap] of Object.entries(props)) {
5119
- const target = root.querySelector(`[data-wcs-ssr-id="${id}"]`);
5120
- if (!target)
5121
- continue;
5122
- for (const [propName, value] of Object.entries(propMap)) {
5123
- target[propName] = value;
5367
+ else {
5368
+ const placeholderComment = findPlaceholderComment(root, block.type, block.uuid);
5369
+ if (placeholderComment) {
5370
+ setContentByNode(placeholderComment, content);
5371
+ // バインディングをアドレスに登録
5372
+ for (const binding of bindingInfos) {
5373
+ const absAddr = getAbsoluteStateAddressByBinding(binding);
5374
+ addBindingByAbsoluteStateAddress(absAddr, binding);
5375
+ }
5124
5376
  }
5125
5377
  }
5126
5378
  }
5127
- // ハイドレーション中の重複登録防止用属性を除去
5128
- const completedEls = root.querySelectorAll('[data-wcs-completed]');
5129
- for (const el of completedEls) {
5130
- el.removeAttribute('data-wcs-completed');
5379
+ // for ブロックの listIndex を state のリスト値に紐づける
5380
+ for (const [uuid, indexes] of listIndexesByUuid) {
5381
+ const placeholderComment = findPlaceholderComment(root, 'for', uuid);
5382
+ if (!placeholderComment)
5383
+ continue;
5384
+ // state から現在のリスト値を取得して listIndexes を設定
5385
+ const rootNode = placeholderComment.getRootNode();
5386
+ // structuralBindings はまだ登録前なので、getParseBindTextResults を直接使う
5387
+ const fragmentInfo = getFragmentInfoByUUID(uuid);
5388
+ if (!fragmentInfo)
5389
+ continue;
5390
+ const stateName = fragmentInfo.parseBindTextResult.stateName;
5391
+ const statePathName = fragmentInfo.parseBindTextResult.statePathName;
5392
+ const stateElement = getStateElementByName(rootNode, stateName);
5393
+ if (!stateElement)
5394
+ continue;
5395
+ stateElement.createState("readonly", (state) => {
5396
+ const list = state[statePathName];
5397
+ if (Array.isArray(list)) {
5398
+ setListIndexesByList(list, indexes);
5399
+ }
5400
+ });
5131
5401
  }
5132
- return true;
5133
5402
  }
5134
-
5135
- const stateElementByNameByNode = new WeakMap();
5136
- const bindingsReadyByNode = new WeakMap();
5137
- function getStateElementByName(rootNode, name) {
5138
- let stateElementByName = stateElementByNameByNode.get(rootNode);
5139
- if (!stateElementByName) {
5403
+ function findPlaceholderComment(root, type, uuid) {
5404
+ const keywordMap = {
5405
+ 'for': config.commentForPrefix,
5406
+ 'if': config.commentIfPrefix,
5407
+ 'elseif': config.commentElseIfPrefix,
5408
+ 'else': config.commentElsePrefix,
5409
+ };
5410
+ const keyword = keywordMap[type];
5411
+ if (!keyword)
5140
5412
  return null;
5141
- }
5142
- return stateElementByName.get(name) || null;
5143
- }
5144
- /**
5145
- * 指定された rootNode のバインディング初期化が完了するまで待機する Promise を返す。
5146
- */
5147
- function getBindingsReady(rootNode) {
5148
- return bindingsReadyByNode.get(rootNode) ?? Promise.resolve();
5149
- }
5150
- function setStateElementByName(rootNode, name, element) {
5151
- let stateElementByName = stateElementByNameByNode.get(rootNode);
5152
- if (element === null) {
5153
- // 削除の場合、Mapが存在しない場合は何もしない
5154
- if (!stateElementByName) {
5155
- return;
5156
- }
5157
- stateElementByName.delete(name);
5158
- if (stateElementByName.size === 0) {
5159
- stateElementByNameByNode.delete(rootNode);
5160
- }
5161
- if (config.debug) {
5162
- console.debug(`State element unregistered: name="${name}"`);
5163
- }
5164
- }
5165
- else {
5166
- // 登録の場合
5167
- if (!stateElementByName) {
5168
- stateElementByName = new Map();
5169
- stateElementByNameByNode.set(rootNode, stateElementByName);
5170
- // 初めてルートノードに登録する場合
5171
- // enable-ssr 属性があり、サーバーサイドでない場合はハイドレーション
5172
- const enableSsr = !inSsr() && element.hasAttribute?.('enable-ssr');
5173
- if (rootNode.constructor.name === 'HTMLDocument' || rootNode.constructor.name === 'Document') {
5174
- const ready = new Promise((resolve) => {
5175
- queueMicrotask(async () => {
5176
- if (enableSsr) {
5177
- const success = await hydrateBindings(rootNode);
5178
- if (!success) {
5179
- await buildBindings(rootNode);
5180
- }
5181
- }
5182
- else {
5183
- await buildBindings(rootNode);
5184
- }
5185
- resolve();
5186
- });
5187
- });
5188
- bindingsReadyByNode.set(rootNode, ready);
5189
- }
5190
- else if (rootNode.constructor.name === 'ShadowRoot') {
5191
- const ready = new Promise((resolve) => {
5192
- queueMicrotask(async () => {
5193
- await buildBindings(rootNode);
5194
- resolve();
5195
- });
5196
- });
5197
- bindingsReadyByNode.set(rootNode, ready);
5198
- }
5199
- }
5200
- if (stateElementByName.has(name)) {
5201
- raiseError(`State element with name "${name}" is already registered.`);
5202
- }
5203
- stateElementByName.set(name, element);
5204
- if (config.debug) {
5205
- console.debug(`State element registered: name="${name}"`, element);
5413
+ const pattern = `@@${keyword}:${uuid}`;
5414
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
5415
+ while (walker.nextNode()) {
5416
+ const comment = walker.currentNode;
5417
+ if (comment.data === pattern) {
5418
+ return comment;
5206
5419
  }
5207
5420
  }
5421
+ return null;
5208
5422
  }
5209
-
5210
- const fragmentInfoByUUID = new Map();
5211
- function setFragmentInfoByUUID(uuid, rootNode, fragmentInfo) {
5212
- if (fragmentInfo === null) {
5213
- fragmentInfoByUUID.delete(uuid);
5214
- }
5215
- else {
5216
- fragmentInfoByUUID.set(uuid, fragmentInfo);
5217
- const bindingPartial = fragmentInfo.parseBindTextResult;
5218
- const stateElement = getStateElementByName(rootNode, bindingPartial.stateName);
5219
- if (stateElement === null) {
5220
- raiseError(`State element with name "${bindingPartial.stateName}" not found for fragment info.`);
5423
+ /**
5424
+ * <wcs-ssr> 内のテンプレートを fragmentInfoByUUID に復帰させる。
5425
+ */
5426
+ function restoreFragments(root, ssrEl) {
5427
+ const rootNode = root;
5428
+ let lastIfParseResult = null;
5429
+ for (const [uuid, tpl] of ssrEl.templates) {
5430
+ const bindText = tpl.getAttribute(config.bindAttributeName) || '';
5431
+ const parseBindTextResults = parseBindTextsForElement(bindText);
5432
+ let parseBindTextResult = parseBindTextResults[0];
5433
+ const bindingType = parseBindTextResult.bindingType;
5434
+ // else: 直前の if 条件の not 条件反転
5435
+ // elseif: 独自条件を持つが stateName は if から引き継ぐ
5436
+ if (bindingType === 'else' && lastIfParseResult) {
5437
+ parseBindTextResult = {
5438
+ ...lastIfParseResult,
5439
+ outFilters: [...lastIfParseResult.outFilters, createNotFilter()],
5440
+ bindingType: 'else',
5441
+ };
5221
5442
  }
5222
- stateElement.setPathInfo(bindingPartial.statePathName, bindingPartial.bindingType);
5223
- for (const nodeInfo of fragmentInfo.nodeInfos) {
5224
- for (const nodeBindingPartial of nodeInfo.parseBindTextResults) {
5225
- const nodeStateElement = getStateElementByName(rootNode, nodeBindingPartial.stateName);
5226
- if (nodeStateElement === null) {
5227
- raiseError(`State element with name "${nodeBindingPartial.stateName}" not found for fragment info node.`);
5228
- }
5229
- nodeStateElement.setPathInfo(nodeBindingPartial.statePathName, nodeBindingPartial.bindingType);
5230
- }
5443
+ else if (bindingType === 'elseif' && lastIfParseResult) {
5444
+ parseBindTextResult = {
5445
+ ...parseBindTextResult,
5446
+ stateName: lastIfParseResult.stateName,
5447
+ };
5448
+ }
5449
+ // if chain の追跡
5450
+ if (bindingType === 'if') {
5451
+ lastIfParseResult = parseBindTextResult;
5452
+ }
5453
+ else if (bindingType === 'elseif') {
5454
+ lastIfParseResult = parseBindTextResult;
5455
+ }
5456
+ else if (bindingType === 'else') {
5457
+ lastIfParseResult = null;
5458
+ }
5459
+ const fragment = document.importNode(tpl.content, true);
5460
+ const forPath = bindingType === "for" ? parseBindTextResult.statePathName : undefined;
5461
+ optimizeFragment(fragment);
5462
+ if (typeof forPath === "string") {
5463
+ expandShorthandPaths(fragment, forPath);
5231
5464
  }
5465
+ collectStructuralFragments(rootNode, fragment, forPath);
5466
+ const fragmentInfo = {
5467
+ fragment,
5468
+ parseBindTextResult,
5469
+ nodeInfos: getFragmentNodeInfos(fragment),
5470
+ };
5471
+ setFragmentInfoByUUID(uuid, rootNode, fragmentInfo);
5232
5472
  }
5233
5473
  }
5234
- function getFragmentInfoByUUID(uuid) {
5235
- return fragmentInfoByUUID.get(uuid) || null;
5236
- }
5237
- function getAllFragmentUUIDs() {
5238
- return Array.from(fragmentInfoByUUID.keys());
5239
- }
5240
-
5241
- // SSR コメントパターン
5242
- const SSR_PLACEHOLDER_COMMENT = /^@@wcs-(?:for|if|elseif|else):[^-]/;
5243
- const SSR_BLOCK_START = /^@@wcs-(for|if|elseif|else)-start:(.+)$/;
5244
- const SSR_BLOCK_END = /^@@wcs-(for|if|elseif|else)-end:(.+)$/;
5245
- const SSR_TEXT_START = /^@@wcs-text-start:(.+)$/;
5246
5474
  /**
5247
- * script 要素へ埋め込む JSON を HTML パーサから保護する。
5248
- * HTML 直列化時、script の中身は生のまま出力されるため、state 値に
5249
- * "</script>" "<!--" を含む文字列があると script を脱出できてしまう。
5250
- * "<" ">" "&" と U+2028/U+2029 を JSON の \uXXXX エスケープへ置換する
5251
- * (JSON.parse では元の文字列と等価に復元される)。
5475
+ * SSR ハイドレーション用バインディング初期化。
5476
+ * バージョン不一致時は DOM をクリーンアップして false を返す
5477
+ * (呼び出し元で buildBindings にフォールバック)。
5252
5478
  */
5253
- function escapeJsonForScript(json) {
5254
- return json
5255
- .replace(/</g, '\\u003c')
5256
- .replace(/>/g, '\\u003e')
5257
- .replace(/&/g, '\\u0026')
5258
- .replace(/\u2028/g, '\\u2028')
5259
- .replace(/\u2029/g, '\\u2029');
5260
- }
5261
- class Ssr extends HTMLElement {
5262
- _stateData = null;
5263
- _templates = null;
5264
- _hydrateProps = null;
5265
- get name() {
5266
- return this.getAttribute('name') || 'default';
5479
+ async function hydrateBindings(root) {
5480
+ await waitForStateInitialize(root);
5481
+ // バージョン検証
5482
+ const ssrElements = root.querySelectorAll(config.tagNames.ssr);
5483
+ for (const ssrNode of ssrElements) {
5484
+ const ssrEl = ssrNode;
5485
+ if (!ssrEl.verifyVersion()) {
5486
+ console.warn(`[@wcstack/state] SSR version mismatch: server="${ssrEl.version}", client="${VERSION}". Falling back to full render.`);
5487
+ Ssr.cleanupDom(root);
5488
+ return false;
5489
+ }
5267
5490
  }
5268
- get version() {
5269
- return this.getAttribute('version') || '';
5491
+ // <wcs-ssr> からテンプレートを fragmentInfoByUUID に復帰
5492
+ for (const ssrNode of ssrElements) {
5493
+ restoreFragments(root, ssrNode);
5270
5494
  }
5271
- get stateData() {
5272
- if (this._stateData === null) {
5273
- this._stateData = this._loadStateData();
5495
+ // SSR ブロック境界コメントから既存 DOM を Content 化
5496
+ const blocks = collectSsrBlocks(document.body);
5497
+ hydrateBlocks(document.body, blocks);
5498
+ // ブロック境界コメント (start/end) を除去
5499
+ Ssr.removeBlockBoundaryComments(document.body);
5500
+ // <wcs-ssr> を一時除去(バインディング走査に含めない)
5501
+ const ssrParents = [];
5502
+ for (const el of ssrElements) {
5503
+ if (el.parentNode) {
5504
+ ssrParents.push({ el, parent: el.parentNode, next: el.nextSibling });
5505
+ el.remove();
5274
5506
  }
5275
- return this._stateData;
5276
5507
  }
5277
- get templates() {
5278
- if (this._templates === null) {
5279
- this._templates = this._loadTemplates();
5508
+ // 構造プレースホルダーコメント (@@wcs-for:uuid 等) は残す
5509
+ // バインディング走査で拾われ、状態変化時の再レンダリングに使われる
5510
+ // SSR テキストバインディングを @@: 形式に復元
5511
+ Ssr.restoreTextBindings(document.body);
5512
+ // ノードとバインディングを収集
5513
+ const [subscriberNodes, allBindings] = collectNodesAndBindingInfos(document.body);
5514
+ // 収集完了したノードに data-wcs-completed 属性を付与
5515
+ // for ブロック内ノード(hydrateBlocks で登録済み)にはループコンテキストをリセットしない
5516
+ for (const node of subscriberNodes) {
5517
+ if (node.nodeType === Node.ELEMENT_NODE) {
5518
+ const el = node;
5519
+ if (!el.hasAttribute('data-wcs-completed')) {
5520
+ setLoopContextByNode(node, null);
5521
+ el.setAttribute('data-wcs-completed', '');
5522
+ }
5280
5523
  }
5281
- return this._templates;
5282
- }
5283
- get hydrateProps() {
5284
- if (this._hydrateProps === null) {
5285
- this._hydrateProps = this._loadHydrateProps();
5524
+ else {
5525
+ // コメントノード等
5526
+ setLoopContextByNode(node, null);
5286
5527
  }
5287
- return this._hydrateProps;
5288
- }
5289
- getTemplate(uuid) {
5290
- return this.templates.get(uuid) ?? null;
5291
- }
5292
- /**
5293
- * サーバーの SSR バージョンとクライアントの state バージョンを検証する。
5294
- * メジャー・マイナーバージョンが一致すればtrue。
5295
- * version 属性がない場合は検証スキップ(true)。
5296
- */
5297
- verifyVersion() {
5298
- const serverVersion = this.version;
5299
- if (!serverVersion)
5300
- return true;
5301
- const serverParts = serverVersion.split('.');
5302
- const clientParts = VERSION.split('.');
5303
- // メジャー・マイナーが一致すれば互換
5304
- return serverParts[0] === clientParts[0] && serverParts[1] === clientParts[1];
5305
- }
5306
- setStateData(data) {
5307
- this._stateData = data;
5308
- }
5309
- setHydrateProps(props) {
5310
- this._hydrateProps = props;
5311
5528
  }
5312
- _loadStateData() {
5313
- const script = this.querySelector(`script[type="application/json"]:not([data-wcs-ssr-props])`);
5314
- if (!script)
5315
- return {};
5316
- try {
5317
- return JSON.parse(script.textContent || '{}');
5529
+ // バインディングを構造系とそれ以外に分離
5530
+ const normalBindings = [];
5531
+ const structuralBindings = [];
5532
+ for (const binding of allBindings) {
5533
+ replaceToReplaceNode(binding);
5534
+ if (attachEventHandler(binding)) {
5535
+ continue;
5318
5536
  }
5319
- catch {
5320
- return {};
5537
+ if (attachEventTokenHandler(binding)) {
5538
+ continue;
5321
5539
  }
5322
- }
5323
- _loadTemplates() {
5324
- const map = new Map();
5325
- const templates = this.querySelectorAll('template[id]');
5326
- for (const tpl of templates) {
5327
- const id = tpl.getAttribute('id');
5328
- if (id) {
5329
- map.set(id, tpl);
5330
- }
5540
+ attachTwowayEventHandler(binding);
5541
+ attachRadioEventHandler(binding);
5542
+ attachCheckboxEventHandler(binding);
5543
+ if (STRUCTURAL_TYPES.has(binding.bindingType)) {
5544
+ structuralBindings.push(binding);
5331
5545
  }
5332
- return map;
5333
- }
5334
- _loadHydrateProps() {
5335
- const script = this.querySelector('script[data-wcs-ssr-props]');
5336
- if (!script)
5337
- return {};
5338
- try {
5339
- return JSON.parse(script.textContent || '{}');
5546
+ else if (binding.statePathName.includes(WILDCARD)) {
5547
+ // for ブロック内のバインディング → Content のバインディングとして登録済み
5548
+ continue;
5340
5549
  }
5341
- catch {
5342
- return {};
5550
+ else {
5551
+ normalBindings.push(binding);
5343
5552
  }
5344
5553
  }
5345
- static findByName(root, name) {
5346
- const tagName = config.tagNames.ssr;
5347
- const parentEl = root instanceof Element
5348
- ? root
5349
- : root instanceof Document
5350
- ? root.documentElement
5351
- : null;
5352
- if (!parentEl)
5353
- return null;
5354
- const el = parentEl.querySelector(`${tagName}[name="${name}"]`);
5355
- return el;
5554
+ // 全バインディング(通常 + 構造)をアドレスに登録
5555
+ for (const binding of [...normalBindings, ...structuralBindings]) {
5556
+ const absoluteStateAddress = getAbsoluteStateAddressByBinding(binding);
5557
+ addBindingByAbsoluteStateAddress(absoluteStateAddress, binding);
5558
+ const rootNode = binding.replaceNode.getRootNode();
5559
+ const stateElement = getStateElementByName(rootNode, binding.stateName);
5560
+ if (stateElement === null) {
5561
+ raiseError(`State element with name "${binding.stateName}" not found for binding.`);
5562
+ }
5563
+ if (binding.bindingType !== 'event') {
5564
+ stateElement.setPathInfo(binding.statePathName, binding.bindingType);
5565
+ }
5356
5566
  }
5357
- /**
5358
- * stateData と構造テンプレート・プロパティから <wcs-ssr> の中身を構築する。
5359
- * server パッケージの renderToString から呼ばれる。
5360
- */
5361
- /**
5362
- * wcs-state 要素から $ プレフィックスや関数を除いたデータを抽出する。
5363
- */
5364
- static extractStateData(stateEl) {
5365
- const raw = stateEl.__state;
5366
- if (!raw || typeof raw !== 'object')
5367
- return {};
5368
- const data = {};
5369
- for (const [key, value] of Object.entries(raw)) {
5370
- if (!key.startsWith('$') && typeof value !== 'function') {
5371
- data[key] = value;
5567
+ // for バインディングの lastListValue を初期値として設定
5568
+ // (次回の状態変化時に差分計算の基準になる)
5569
+ for (const binding of structuralBindings) {
5570
+ if (binding.bindingType === 'for') {
5571
+ const absAddr = getAbsoluteStateAddressByBinding(binding);
5572
+ const rootNode = binding.replaceNode.getRootNode();
5573
+ const stateElement = getStateElementByName(rootNode, binding.stateName);
5574
+ if (stateElement) {
5575
+ stateElement.createState("readonly", (state) => {
5576
+ const value = state[binding.statePathName];
5577
+ if (Array.isArray(value)) {
5578
+ setLastListValueByAbsoluteStateAddress(absAddr, value);
5579
+ }
5580
+ });
5372
5581
  }
5373
5582
  }
5374
- return data;
5375
5583
  }
5376
- static buildContent(ssrEl, stateData) {
5377
- // 初期データ JSON
5378
- const jsonScript = document.createElement('script');
5379
- jsonScript.setAttribute('type', 'application/json');
5380
- jsonScript.textContent = escapeJsonForScript(JSON.stringify(stateData));
5381
- ssrEl.appendChild(jsonScript);
5382
- // UUID で管理されているテンプレートを復元して格納
5383
- const uuids = getAllFragmentUUIDs();
5384
- for (const uuid of uuids) {
5385
- const fragmentInfo = getFragmentInfoByUUID(uuid);
5386
- if (!fragmentInfo)
5584
+ // 通常バインディングのみ初回値適用(構造バインディングはSSR描画済み)
5585
+ applyChangeFromBindings(normalBindings);
5586
+ // <wcs-ssr> を元に戻す
5587
+ for (const { el, parent, next } of ssrParents) {
5588
+ parent.insertBefore(el, next);
5589
+ }
5590
+ // hydrateProps 復元
5591
+ const restoredSsrElements = root.querySelectorAll(config.tagNames.ssr);
5592
+ for (const ssrNode of restoredSsrElements) {
5593
+ const ssrEl = ssrNode;
5594
+ const props = ssrEl.hydrateProps;
5595
+ for (const [id, propMap] of Object.entries(props)) {
5596
+ const target = root.querySelector(`[data-wcs-ssr-id="${id}"]`);
5597
+ if (!target)
5387
5598
  continue;
5388
- const tpl = document.createElement('template');
5389
- tpl.setAttribute('id', uuid);
5390
- const bindResult = fragmentInfo.parseBindTextResult;
5391
- const bindText = bindResult.bindingType === 'else'
5392
- ? 'else:'
5393
- : `${bindResult.bindingType}: ${bindResult.statePathName}`;
5394
- tpl.setAttribute(config.bindAttributeName, bindText);
5395
- const content = fragmentInfo.fragment.cloneNode(true);
5396
- tpl.content.appendChild(content);
5397
- ssrEl.appendChild(tpl);
5398
- }
5399
- // 属性で代替不可なプロパティをハイドレーション用に格納
5400
- const ssrNodes = getAllSsrPropertyNodes();
5401
- if (ssrNodes.length > 0) {
5402
- const propsData = {};
5403
- for (let i = 0; i < ssrNodes.length; i++) {
5404
- const node = ssrNodes[i];
5405
- const entries = getSsrProperties(node);
5406
- if (entries.length === 0)
5407
- continue;
5408
- const id = `wcs-ssr-${i}`;
5409
- node.setAttribute('data-wcs-ssr-id', id);
5410
- const props = {};
5411
- for (const entry of entries) {
5412
- props[entry.propName] = entry.value;
5413
- }
5414
- propsData[id] = props;
5415
- }
5416
- if (Object.keys(propsData).length > 0) {
5417
- const propsScript = document.createElement('script');
5418
- propsScript.setAttribute('type', 'application/json');
5419
- propsScript.setAttribute('data-wcs-ssr-props', '');
5420
- propsScript.textContent = escapeJsonForScript(JSON.stringify(propsData));
5421
- ssrEl.appendChild(propsScript);
5599
+ for (const [propName, value] of Object.entries(propMap)) {
5600
+ target[propName] = value;
5422
5601
  }
5423
5602
  }
5424
- clearSsrPropertyStore();
5425
5603
  }
5426
- /**
5427
- * SSR ブロック境界コメント (@@wcs-*-start/end) を除去する
5428
- */
5429
- static removeBlockBoundaryComments(root) {
5430
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
5431
- const toRemove = [];
5432
- while (walker.nextNode()) {
5433
- const comment = walker.currentNode;
5434
- if (SSR_BLOCK_START.test(comment.data) || SSR_BLOCK_END.test(comment.data)) {
5435
- toRemove.push(comment);
5604
+ // ハイドレーション中の重複登録防止用属性を除去
5605
+ const completedEls = root.querySelectorAll('[data-wcs-completed]');
5606
+ for (const el of completedEls) {
5607
+ el.removeAttribute('data-wcs-completed');
5608
+ }
5609
+ return true;
5610
+ }
5611
+
5612
+ const stateElementByNameByNode = new WeakMap();
5613
+ const bindingsReadyByNode = new WeakMap();
5614
+ // devtools 用の列挙可能な登録簿(protocol §4.1 — 唯一の常時 ON 台帳)。
5615
+ // サイズは <wcs-state> 要素数に拘束され、unregister(disconnectedCallback)で
5616
+ // 必ず削除されるためリークしない。
5617
+ const liveStateElements = new Set();
5618
+ function getLiveStateElements() {
5619
+ return liveStateElements;
5620
+ }
5621
+ function getStateElementByName(rootNode, name) {
5622
+ let stateElementByName = stateElementByNameByNode.get(rootNode);
5623
+ if (!stateElementByName) {
5624
+ return null;
5625
+ }
5626
+ return stateElementByName.get(name) || null;
5627
+ }
5628
+ /**
5629
+ * 指定された rootNode のバインディング初期化が完了するまで待機する Promise を返す。
5630
+ */
5631
+ function getBindingsReady(rootNode) {
5632
+ return bindingsReadyByNode.get(rootNode) ?? Promise.resolve();
5633
+ }
5634
+ function setStateElementByName(rootNode, name, element) {
5635
+ let stateElementByName = stateElementByNameByNode.get(rootNode);
5636
+ if (element === null) {
5637
+ // 削除の場合、Mapが存在しない場合は何もしない
5638
+ if (!stateElementByName) {
5639
+ return;
5640
+ }
5641
+ const removed = stateElementByName.get(name);
5642
+ stateElementByName.delete(name);
5643
+ if (stateElementByName.size === 0) {
5644
+ stateElementByNameByNode.delete(rootNode);
5645
+ }
5646
+ if (removed !== undefined) {
5647
+ liveStateElements.delete(removed);
5648
+ if (devtoolsSink !== null) {
5649
+ devtoolsSink({ type: "state:element-unregistered", name, rootNode, element: removed });
5436
5650
  }
5437
5651
  }
5438
- for (const comment of toRemove) {
5439
- comment.remove();
5652
+ if (config.debug) {
5653
+ console.debug(`State element unregistered: name="${name}"`);
5440
5654
  }
5441
5655
  }
5442
- /**
5443
- * SSR の構造プレースホルダーコメント (@@wcs-for:uuid 等) を除去する
5444
- */
5445
- static removeStructuralComments(root) {
5446
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
5447
- const toRemove = [];
5448
- while (walker.nextNode()) {
5449
- const comment = walker.currentNode;
5450
- if (SSR_PLACEHOLDER_COMMENT.test(comment.data)) {
5451
- toRemove.push(comment);
5656
+ else {
5657
+ // 登録の場合
5658
+ if (!stateElementByName) {
5659
+ stateElementByName = new Map();
5660
+ stateElementByNameByNode.set(rootNode, stateElementByName);
5661
+ // 初めてルートノードに登録する場合
5662
+ // enable-ssr 属性があり、サーバーサイドでない場合はハイドレーション
5663
+ const enableSsr = !inSsr() && element.hasAttribute?.('enable-ssr');
5664
+ if (rootNode.constructor.name === 'HTMLDocument' || rootNode.constructor.name === 'Document') {
5665
+ const ready = new Promise((resolve) => {
5666
+ queueMicrotask(async () => {
5667
+ if (enableSsr) {
5668
+ const success = await hydrateBindings(rootNode);
5669
+ if (!success) {
5670
+ await buildBindings(rootNode);
5671
+ }
5672
+ }
5673
+ else {
5674
+ await buildBindings(rootNode);
5675
+ }
5676
+ resolve();
5677
+ });
5678
+ });
5679
+ bindingsReadyByNode.set(rootNode, ready);
5680
+ }
5681
+ else if (rootNode.constructor.name === 'ShadowRoot') {
5682
+ const ready = new Promise((resolve) => {
5683
+ queueMicrotask(async () => {
5684
+ await buildBindings(rootNode);
5685
+ resolve();
5686
+ });
5687
+ });
5688
+ bindingsReadyByNode.set(rootNode, ready);
5452
5689
  }
5453
5690
  }
5454
- for (const comment of toRemove) {
5455
- comment.remove();
5691
+ if (stateElementByName.has(name)) {
5692
+ raiseError(`State element with name "${name}" is already registered.`);
5693
+ }
5694
+ stateElementByName.set(name, element);
5695
+ liveStateElements.add(element);
5696
+ if (devtoolsSink !== null) {
5697
+ devtoolsSink({ type: "state:element-registered", name, rootNode, element });
5698
+ }
5699
+ if (config.debug) {
5700
+ console.debug(`State element registered: name="${name}"`, element);
5456
5701
  }
5457
5702
  }
5458
- /**
5459
- * SSR テキストバインディングコメントを復元する。
5460
- * <!--@@wcs-text-start:path-->text<!--@@wcs-text-end:path-->
5461
- * → <!--@@: path--> (バインディングシステムが認識する形式)
5462
- */
5463
- static restoreTextBindings(root) {
5464
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
5465
- const startComments = [];
5466
- while (walker.nextNode()) {
5467
- const comment = walker.currentNode;
5468
- const match = SSR_TEXT_START.exec(comment.data);
5469
- if (match) {
5470
- startComments.push({ comment, path: match[1] });
5703
+ }
5704
+
5705
+ const updateBatchListeners = new Set();
5706
+ /**
5707
+ * drain 終了リスナーを登録する。
5708
+ */
5709
+ function registerUpdateBatchListener(listener) {
5710
+ updateBatchListeners.add(listener);
5711
+ }
5712
+ /**
5713
+ * drain 終了リスナーを解除する(テスト間の分離用)。
5714
+ */
5715
+ function unregisterUpdateBatchListener(listener) {
5716
+ updateBatchListeners.delete(listener);
5717
+ }
5718
+ /**
5719
+ * 全リスナーに drain のバッチを通知する。
5720
+ * リスナーの throw は握りつぶさない(内部バグの隠蔽防止)。
5721
+ * stream 側リスナーが entry ごとに自前で try/catch する契約(設計書 §3-2)。
5722
+ */
5723
+ function notifyUpdateBatchListeners(batch) {
5724
+ for (const listener of updateBatchListeners) {
5725
+ listener(batch);
5726
+ }
5727
+ }
5728
+ class Updater {
5729
+ _queueAbsoluteAddresses = [];
5730
+ constructor() {
5731
+ }
5732
+ enqueueAbsoluteAddress(absoluteAddress) {
5733
+ const requireStartProcess = this._queueAbsoluteAddresses.length === 0;
5734
+ this._queueAbsoluteAddresses.push(absoluteAddress);
5735
+ if (requireStartProcess) {
5736
+ queueMicrotask(() => {
5737
+ const absoluteAddresses = this._queueAbsoluteAddresses;
5738
+ this._queueAbsoluteAddresses = [];
5739
+ this._applyChange(absoluteAddresses);
5740
+ });
5741
+ }
5742
+ }
5743
+ // テスト用に公開
5744
+ testApplyChange(absoluteAddresses) {
5745
+ this._applyChange(absoluteAddresses);
5746
+ }
5747
+ _applyChange(absoluteAddresses) {
5748
+ // Note: AbsoluteStateAddress はキャッシュされているため、
5749
+ // 同一の (stateName, address) は同じインスタンスとなり、
5750
+ // Set による重複排除が正しく機能する
5751
+ const absoluteAddressSet = new Set(absoluteAddresses);
5752
+ const processBindings = [];
5753
+ for (const absoluteAddress of absoluteAddressSet) {
5754
+ // peek: バインディングの無いアドレス(リスト置換で enqueue される中間
5755
+ // アドレス等)に空 Set を生成・蓄積しない
5756
+ const bindings = peekBindingSetByAbsoluteStateAddress(absoluteAddress);
5757
+ if (bindings === undefined) {
5758
+ continue;
5471
5759
  }
5472
- }
5473
- for (const { comment, path } of startComments) {
5474
- const bindComment = document.createComment(`@@: ${path}`);
5475
- comment.parentNode.insertBefore(bindComment, comment);
5476
- let sibling = comment.nextSibling;
5477
- comment.remove();
5478
- const endPattern = `@@wcs-text-end:${path}`;
5479
- while (sibling) {
5480
- const next = sibling.nextSibling;
5481
- if (sibling.nodeType === Node.COMMENT_NODE && sibling.data === endPattern) {
5482
- sibling.parentNode.removeChild(sibling);
5483
- break;
5760
+ for (const binding of bindings) {
5761
+ if (binding.replaceNode.isConnected === false) {
5762
+ // 切断されているバインディングは無視
5763
+ continue;
5484
5764
  }
5485
- sibling.parentNode.removeChild(sibling);
5486
- sibling = next;
5765
+ processBindings.push(binding);
5487
5766
  }
5488
5767
  }
5768
+ applyChangeFromBindings(processBindings);
5769
+ // drain 終了フック: binding 適用後に dedup 済みバッチを通知する(設計書 §3-2)。
5770
+ // testApplyChange も同じ _applyChange を通るため、テストから同期に駆動できる。
5771
+ notifyUpdateBatchListeners(absoluteAddressSet);
5489
5772
  }
5490
- /**
5491
- * SSR DOM をクリーンアップし、buildBindings が動作できる状態に戻す。
5492
- * バージョン不一致時のフォールバック用。
5493
- *
5494
- * 1. SSR ブロック境界コメント間のレンダリング済みノードを除去
5495
- * 2. SSR テキストバインディングを @@: 形式に復元
5496
- * 3. プレースホルダーコメントを <wcs-ssr> 内のテンプレートで差し替え
5497
- * 4. data-wcs-ssr-id 属性を除去
5498
- * 5. <wcs-ssr> を除去
5499
- */
5500
- static cleanupDom(root) {
5501
- const body = document.body;
5502
- // <wcs-ssr> からテンプレート UUID マップを構築(カスタム要素未定義でも動作するよう DOM 直接走査)
5503
- const ssrElements = root.querySelectorAll(config.tagNames.ssr);
5504
- const templateByUuid = new Map();
5505
- for (const ssrNode of ssrElements) {
5506
- const templates = ssrNode.querySelectorAll('template[id]');
5507
- for (const tpl of templates) {
5508
- const id = tpl.getAttribute('id');
5509
- if (id) {
5510
- templateByUuid.set(id, tpl);
5511
- }
5512
- }
5773
+ }
5774
+ const updater = new Updater();
5775
+ function getUpdater() {
5776
+ return updater;
5777
+ }
5778
+
5779
+ /**
5780
+ * devtools/types.ts
5781
+ *
5782
+ * DevTools Hook Protocol (docs/devtools-hook-protocol.md) の型定義。
5783
+ *
5784
+ * イベント payload はランタイム内部オブジェクト(IAbsoluteStateAddress /
5785
+ * IBindingInfo 等)への生参照を含む(同一 realm・オーバーレイ前提、protocol 原則 4)。
5786
+ * 消費者はこれらを変異してはならない。
5787
+ */
5788
+ /** グローバル registry のプロパティ名 */
5789
+ const DEVTOOLS_HOOK_GLOBAL = "__WCSTACK_DEVTOOLS_HOOK__";
5790
+ /** プロトコル版。additive change では上げない(protocol §2) */
5791
+ const DEVTOOLS_PROTOCOL_VERSION = 1;
5792
+
5793
+ /**
5794
+ * devtools/bridge.ts
5795
+ *
5796
+ * DevTools Hook Protocol (docs/devtools-hook-protocol.md) の state 側実装。
5797
+ *
5798
+ * - registry 最小実装: `globalThis.__WCSTACK_DEVTOOLS_HOOK__` を create-if-missing で
5799
+ * 確保する(ロード順非依存・先勝ち。devtools 側 client も同一仕様の実装を持つ)。
5800
+ * - source: この state モジュールコピーを 1 source として登録する。同一ページに
5801
+ * コピーが複数あれば複数 source になる(正常系、protocol §5)。
5802
+ * - sink 切替: listener の有無に応じて registry が `_setSink` を呼び、ここで
5803
+ * updater の drain リスナー登録/解除も連動させる(protocol §4.3)。
5804
+ */
5805
+ /**
5806
+ * registry の最小実装(protocol §2)。30 行程度に抑え、振る舞いは
5807
+ * 「source/listener の管理と sink の配線」のみ。台帳・整形は devtools 側の責務。
5808
+ */
5809
+ function createMinimalRegistry() {
5810
+ const sources = new Map();
5811
+ const listeners = new Set();
5812
+ const applySink = (source) => {
5813
+ if (listeners.size === 0) {
5814
+ source._setSink(null);
5815
+ return;
5513
5816
  }
5514
- // SSR ブロック境界コメント間のレンダリング済みノードと境界コメントを除去
5515
- const walker1 = document.createTreeWalker(body, NodeFilter.SHOW_COMMENT);
5516
- const startComments = [];
5517
- while (walker1.nextNode()) {
5518
- const comment = walker1.currentNode;
5519
- if (SSR_BLOCK_START.test(comment.data)) {
5520
- startComments.push(comment);
5817
+ const sourceId = source.id;
5818
+ source._setSink((event) => {
5819
+ for (const listener of listeners) {
5820
+ listener.onEvent?.(sourceId, event);
5521
5821
  }
5522
- }
5523
- for (const startComment of startComments) {
5524
- const match = SSR_BLOCK_START.exec(startComment.data);
5525
- const type = match[1];
5526
- const info = match[2];
5527
- const endPattern = `@@wcs-${type}-end:${info}`;
5528
- let sibling = startComment.nextSibling;
5529
- while (sibling) {
5530
- const next = sibling.nextSibling;
5531
- if (sibling.nodeType === Node.COMMENT_NODE && sibling.data === endPattern) {
5532
- sibling.remove();
5533
- break;
5534
- }
5535
- sibling.remove();
5536
- sibling = next;
5822
+ });
5823
+ };
5824
+ return {
5825
+ version: DEVTOOLS_PROTOCOL_VERSION,
5826
+ sources,
5827
+ register(source) {
5828
+ if (sources.has(source.id)) {
5829
+ return;
5537
5830
  }
5538
- startComment.remove();
5539
- }
5540
- // SSR テキストバインディングを @@: 形式に復元
5541
- Ssr.restoreTextBindings(body);
5542
- // プレースホルダーコメント (@@wcs-for:uuid 等) をテンプレートに差し替え
5543
- const walker2 = document.createTreeWalker(body, NodeFilter.SHOW_COMMENT);
5544
- const placeholders = [];
5545
- while (walker2.nextNode()) {
5546
- const comment = walker2.currentNode;
5547
- if (SSR_PLACEHOLDER_COMMENT.test(comment.data)) {
5548
- const uuid = comment.data.split(':')[1];
5549
- placeholders.push({ comment, uuid });
5831
+ sources.set(source.id, source);
5832
+ applySink(source);
5833
+ for (const listener of listeners) {
5834
+ listener.onSourceRegistered?.(source);
5550
5835
  }
5551
- }
5552
- for (const { comment, uuid } of placeholders) {
5553
- const tpl = templateByUuid.get(uuid);
5554
- if (tpl) {
5555
- const restored = document.createElement('template');
5556
- const bindAttr = tpl.getAttribute(config.bindAttributeName);
5557
- if (bindAttr)
5558
- restored.setAttribute(config.bindAttributeName, bindAttr);
5559
- const imported = document.importNode(tpl.content, true);
5560
- if (imported.childNodes.length > 0) {
5561
- restored.content.appendChild(imported);
5836
+ },
5837
+ unregister(sourceId) {
5838
+ const source = sources.get(sourceId);
5839
+ if (source === undefined) {
5840
+ return;
5841
+ }
5842
+ source._setSink(null);
5843
+ sources.delete(sourceId);
5844
+ for (const listener of listeners) {
5845
+ listener.onSourceUnregistered?.(sourceId);
5846
+ }
5847
+ },
5848
+ addListener(listener) {
5849
+ listeners.add(listener);
5850
+ // 既登録 source をリプレイ(遅延アタッチの起点、protocol §6)
5851
+ for (const source of sources.values()) {
5852
+ applySink(source);
5853
+ listener.onSourceRegistered?.(source);
5854
+ }
5855
+ return () => {
5856
+ if (!listeners.delete(listener)) {
5857
+ return;
5562
5858
  }
5563
- else {
5564
- for (const child of Array.from(tpl.childNodes)) {
5565
- restored.content.appendChild(document.importNode(child, true));
5566
- }
5859
+ for (const source of sources.values()) {
5860
+ applySink(source);
5567
5861
  }
5568
- comment.parentNode.replaceChild(restored, comment);
5569
- }
5570
- }
5571
- // data-wcs-ssr-id 属性を除去
5572
- const ssrIdElements = root.querySelectorAll('[data-wcs-ssr-id]');
5573
- for (const el of ssrIdElements) {
5574
- el.removeAttribute('data-wcs-ssr-id');
5575
- }
5576
- // <wcs-ssr> を除去
5577
- for (const el of ssrElements) {
5578
- el.remove();
5862
+ };
5863
+ },
5864
+ };
5865
+ }
5866
+ function getOrCreateHookRegistry() {
5867
+ const globals = globalThis;
5868
+ const existing = globals[DEVTOOLS_HOOK_GLOBAL];
5869
+ if (existing !== undefined) {
5870
+ if (existing.version !== DEVTOOLS_PROTOCOL_VERSION) {
5871
+ // 先勝ち固定。振る舞いは差し替えない(protocol §2)
5872
+ console.warn(`[wcstack/state] devtools hook registry version mismatch: found ${existing.version}, expected ${DEVTOOLS_PROTOCOL_VERSION}. Keeping the existing registry (first-wins).`);
5579
5873
  }
5874
+ return existing;
5875
+ }
5876
+ const registry = createMinimalRegistry();
5877
+ globals[DEVTOOLS_HOOK_GLOBAL] = registry;
5878
+ return registry;
5879
+ }
5880
+ /**
5881
+ * drain 終了バッチの転送リスナー。sink 接続中のみ updater に登録される。
5882
+ */
5883
+ const onUpdateBatch = (batch) => {
5884
+ if (devtoolsSink !== null) {
5885
+ devtoolsSink({ type: "state:update-batch", addresses: batch });
5886
+ }
5887
+ };
5888
+ /**
5889
+ * registry からの sink 差し替え。updater の drain リスナー登録/解除を連動させる。
5890
+ * detach 時に登録が残らないこと(protocol §7-2)。
5891
+ */
5892
+ function setSink(sink) {
5893
+ const wasActive = devtoolsSink !== null;
5894
+ setDevtoolsSink(sink);
5895
+ const isActive = sink !== null;
5896
+ if (isActive && !wasActive) {
5897
+ registerUpdateBatchListener(onUpdateBatch);
5898
+ }
5899
+ else if (!isActive && wasActive) {
5900
+ unregisterUpdateBatchListener(onUpdateBatch);
5901
+ }
5902
+ }
5903
+ function createStateElementSummary(element) {
5904
+ return {
5905
+ name: element.name,
5906
+ rootNode: element.rootNode,
5907
+ element,
5908
+ paths: {
5909
+ list: element.listPaths,
5910
+ element: element.elementPaths,
5911
+ getter: element.getterPaths,
5912
+ setter: element.setterPaths,
5913
+ },
5914
+ commandTokenNames: element.commandTokenNames,
5915
+ eventTokenNames: element.eventTokenNames,
5916
+ staticDependency: element.staticDependency,
5917
+ dynamicDependency: element.dynamicDependency,
5918
+ };
5919
+ }
5920
+ function requireStateElement(name, rootNode) {
5921
+ return getStateElementByName(rootNode, name) ??
5922
+ raiseError(`devtools: state element not found: name="${name}"`);
5923
+ }
5924
+ function createSourceId() {
5925
+ // getUUID() はモジュールローカル連番のため、state コピーが複数ある
5926
+ // ページで source id が衝突する。ランダム採番で回避する。
5927
+ return "state:" + Math.random().toString(36).slice(2, 10);
5928
+ }
5929
+ let registeredSource = null;
5930
+ /**
5931
+ * この state ランタイムを 1 source として registry に登録する。
5932
+ * bootstrapState() から呼ばれる。冪等・SSR では何もしない(protocol 原則 6)。
5933
+ */
5934
+ function registerDevtoolsSource() {
5935
+ if (inSsr()) {
5936
+ return;
5937
+ }
5938
+ if (registeredSource !== null) {
5939
+ return;
5580
5940
  }
5941
+ const source = {
5942
+ id: createSourceId(),
5943
+ kind: "state",
5944
+ packageVersion: VERSION,
5945
+ getStateElements() {
5946
+ const summaries = [];
5947
+ for (const element of getLiveStateElements()) {
5948
+ summaries.push(createStateElementSummary(element));
5949
+ }
5950
+ return summaries;
5951
+ },
5952
+ keys(name, rootNode) {
5953
+ const element = requireStateElement(name, rootNode);
5954
+ const result = [];
5955
+ element.createState("readonly", (state) => {
5956
+ // Object.keys は Proxy の ownKeys 経由で target の own key を返す。
5957
+ // メソッド判別の typeof アクセスは getter を 1 回実行する副作用があるため、
5958
+ // ループ文脈依存で throw する getter は catch して「キーとしては存在する」
5959
+ // 側に倒す(値の表示可否は UI 側の責務)。
5960
+ for (const key of Object.keys(state)) {
5961
+ if (key.includes("*") || key.startsWith("$")) {
5962
+ continue;
5963
+ }
5964
+ try {
5965
+ if (typeof state[key] === "function") {
5966
+ continue;
5967
+ }
5968
+ }
5969
+ catch {
5970
+ // 読めない getter もキーとしては列挙する
5971
+ }
5972
+ result.push(key);
5973
+ }
5974
+ });
5975
+ return result;
5976
+ },
5977
+ read(name, rootNode, path, indexes) {
5978
+ const element = requireStateElement(name, rootNode);
5979
+ let result;
5980
+ element.createState("readonly", (state) => {
5981
+ result = state["$resolve"](path, indexes ?? []);
5982
+ });
5983
+ return result;
5984
+ },
5985
+ write(name, rootNode, path, value, indexes) {
5986
+ const element = requireStateElement(name, rootNode);
5987
+ element.createState("writable", (state) => {
5988
+ if (indexes !== undefined && indexes.length > 0) {
5989
+ // Note: $resolve は value===undefined を「取得」と解釈するため、
5990
+ // ワイルドカードパスへの undefined 書き込みは非サポート
5991
+ // (spread undefined 規範と同じ側に倒す)
5992
+ state["$resolve"](path, indexes, value);
5993
+ }
5994
+ else {
5995
+ state[path] = value;
5996
+ }
5997
+ });
5998
+ },
5999
+ _setSink: setSink,
6000
+ };
6001
+ registeredSource = source;
6002
+ getOrCreateHookRegistry().register(source);
5581
6003
  }
5582
6004
 
5583
6005
  async function loadFromInnerScript(script, name) {
@@ -5666,8 +6088,14 @@ class LoopContextStack {
5666
6088
  }
5667
6089
  }
5668
6090
  else {
5669
- if (loopContext.pathInfo.wildcardCount !== 1) {
5670
- raiseError(`Cannot push loop context for a list with wildcard positions when there is no active loop context.`);
6091
+ // With no active loop context the address must be self-contained: the
6092
+ // listIndex chain supplies one index per wildcard. Top-level lists
6093
+ // (wildcardCount 1) always satisfy this. A nested list re-rendered
6094
+ // directly (e.g. replaced via $resolve from outside the loop) also
6095
+ // satisfies it — the for binding's listIndex carries the full ancestor
6096
+ // chain.
6097
+ if (loopContext.listIndex.length !== loopContext.pathInfo.wildcardCount) {
6098
+ raiseError(`Cannot push loop context when there is no active loop context: the list index chain (length ${loopContext.listIndex.length}) does not cover the wildcard path (wildcard count ${loopContext.pathInfo.wildcardCount}).`);
5671
6099
  }
5672
6100
  }
5673
6101
  this._loopContextStack[this._length] = loopContext;
@@ -5739,7 +6167,7 @@ function getOrCreateCommandToken(stateElement, name) {
5739
6167
  }
5740
6168
  let token = registry.get(name);
5741
6169
  if (typeof token === "undefined") {
5742
- token = new CommandToken(name);
6170
+ token = new CommandToken(name, stateElement.name);
5743
6171
  registry.set(name, token);
5744
6172
  }
5745
6173
  return token;
@@ -6259,74 +6687,6 @@ function clearStreamNamespace(stateElement) {
6259
6687
  errorNamespaceByStateElement.delete(stateElement);
6260
6688
  }
6261
6689
 
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
6690
  /**
6331
6691
  * stream/argsTrace.ts
6332
6692
  *
@@ -7616,11 +7976,17 @@ function setByAddress(target, address, value, receiver, handler) {
7616
7976
  // primitive 値かつ Object.is 同値なら、set / enqueue / walkDependency / DOM 適用 /
7617
7977
  // $updatedCallback / DCC イベントを丸ごとスキップ(標準的なリアクティブ no-op)。
7618
7978
  // 参照型(object/array)は in-place mutation 取りこぼし防止のため素通し(ガードしない)。
7979
+ // devtools write イベント用: guard が既に取得した旧値のみ流用する
7980
+ // (参照型のために追加の get はしない — protocol §4.2)
7981
+ let devOldValue;
7982
+ let devHasOldValue = false;
7619
7983
  if (config.sameValueGuard && (value === null || typeof value !== "object")) {
7620
7984
  const oldValue = getByAddress(target, address, receiver, handler);
7621
7985
  if (Object.is(oldValue, value)) {
7622
7986
  return true;
7623
7987
  }
7988
+ devOldValue = oldValue;
7989
+ devHasOldValue = true;
7624
7990
  }
7625
7991
  // --- end same-value guard ---
7626
7992
  const isSwappable = stateElement.elementPaths.has(address.pathInfo.path);
@@ -7628,6 +7994,15 @@ function setByAddress(target, address, value, receiver, handler) {
7628
7994
  stateElement.getterPaths.has(address.pathInfo.path);
7629
7995
  const absPathInfo = getAbsolutePathInfo(stateElement, address.pathInfo);
7630
7996
  const absAddress = createAbsoluteStateAddress(absPathInfo, address.listIndex);
7997
+ if (devtoolsSink !== null) {
7998
+ devtoolsSink({
7999
+ type: "state:write",
8000
+ absoluteAddress: absAddress,
8001
+ value,
8002
+ oldValue: devOldValue,
8003
+ hasOldValue: devHasOldValue,
8004
+ });
8005
+ }
7631
8006
  try {
7632
8007
  if (isSwappable) {
7633
8008
  return _setByAddressWithSwap(target, address, absAddress, value, receiver, handler);
@@ -8002,15 +8377,16 @@ async function setLoopContextAsync(handler, loopContext, callback) {
8002
8377
  * StateClassのProxyトラップとして、プロパティアクセス時の値取得処理を担う関数(get)の実装です。
8003
8378
  *
8004
8379
  * 主な役割:
8005
- * - 文字列プロパティの場合、特殊プロパティ($1〜$9, $resolve, $getAll, $navigate)に応じた値やAPIを返却
8380
+ * - 文字列プロパティの場合、特殊プロパティ($1〜、$stateElement, $getAll, $postUpdate,
8381
+ * $resolve, $trackDependency, $command, $streamStatus, $streamError)に応じた値やAPIを返却
8006
8382
  * - 通常のプロパティはgetResolvedPathInfoでパス情報を解決し、getListIndexでリストインデックスを取得
8007
8383
  * - getByRefで構造化パス・リストインデックスに対応した値を取得
8008
8384
  * - シンボルプロパティの場合はhandler.callableApi経由でAPIを呼び出し
8009
8385
  * - それ以外はReflect.getで通常のプロパティアクセスを実行
8010
8386
  *
8011
8387
  * 設計ポイント:
8012
- * - $1〜$9は直近のStatePropertyRefのリストインデックス値を返す特殊プロパティ
8013
- * - $resolve, $getAll, $navigateはAPI関数やルーターインスタンスを返す
8388
+ * - $1〜$128(MAX_WILDCARD_DEPTH)は直近のStatePropertyRefのリストインデックス値を返す特殊プロパティ
8389
+ * - $getAll, $resolve 等はAPI関数を、$command / $streamStatus / $streamError は名前空間を返す
8014
8390
  * - 通常のプロパティアクセスもバインディングや多重ループに対応
8015
8391
  * - シンボルAPIやReflect.getで拡張性・互換性も確保
8016
8392
  */
@@ -9183,6 +9559,8 @@ function bootstrapState(config) {
9183
9559
  setConfig(config);
9184
9560
  }
9185
9561
  registerComponents();
9562
+ // DevTools Hook Protocol への source 登録(SSR では no-op・冪等)
9563
+ registerDevtoolsSource();
9186
9564
  }
9187
9565
 
9188
9566
  /**
@@ -9382,6 +9760,9 @@ function getWcsManifest() {
9382
9760
  STATE_COMMAND_NAMESPACE_NAME,
9383
9761
  STATE_EVENT_TOKENS_NAME,
9384
9762
  STATE_ON_NAME,
9763
+ STATE_STREAMS_NAME,
9764
+ STATE_STREAM_STATUS_NAMESPACE_NAME,
9765
+ STATE_STREAM_ERROR_NAMESPACE_NAME,
9385
9766
  ],
9386
9767
  };
9387
9768
  }