@wcstack/state 1.19.1 → 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
@@ -65,16 +65,6 @@ function setConfig(partialConfig) {
65
65
  }
66
66
  }
67
67
 
68
- var version$1 = "1.19.1";
69
- var pkg = {
70
- version: version$1};
71
-
72
- const VERSION = pkg.version;
73
-
74
- function raiseError(message) {
75
- throw new Error(`[@wcstack/state] ${message}`);
76
- }
77
-
78
68
  const bindingPromiseByNode = new WeakMap();
79
69
  let id$1 = 0;
80
70
  function getInitializeBindingPromiseByNode(node) {
@@ -103,6 +93,10 @@ function resolveInitializedBinding(node) {
103
93
  bindingPromise.resolve();
104
94
  }
105
95
 
96
+ function raiseError(message) {
97
+ throw new Error(`[@wcstack/state] ${message}`);
98
+ }
99
+
106
100
  function replaceToReplaceNode(bindingInfo) {
107
101
  const node = bindingInfo.node;
108
102
  const replaceNode = bindingInfo.replaceNode;
@@ -1593,6 +1587,37 @@ function parseBindTextForEmbeddedNode(bindText) {
1593
1587
  };
1594
1588
  }
1595
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
+
1596
1621
  function getParseBindTextResults(node) {
1597
1622
  if (node.nodeType === Node.ELEMENT_NODE) {
1598
1623
  const element = node;
@@ -1775,6 +1800,23 @@ function createStateAddress(pathInfo, listIndex) {
1775
1800
  }
1776
1801
  }
1777
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
+
1778
1820
  // command-token / event-token が共有する pub/sub プリミティブ。
1779
1821
  // _subscribers は Set のため挿入順を保持する。
1780
1822
  // emit() は subscribe() された順に呼び出され、戻り値配列も同じ順序で返る。
@@ -1814,7 +1856,31 @@ class Token {
1814
1856
 
1815
1857
  // CommandToken は共有 pub/sub プリミティブ Token の薄い特化。
1816
1858
  // instanceof による型判別を成立させるため独立クラスとして維持する。
1859
+ //
1860
+ // ownerStateName は devtools 計装(protocol §4.5)のための内部 optional 引数。
1861
+ // command-token-protocol の外部仕様は不変更(registry が渡すだけで、
1862
+ // subscribe/emit の意味論には一切影響しない)。
1817
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
+ }
1818
1884
  }
1819
1885
  function isCommandToken(value) {
1820
1886
  return value instanceof CommandToken;
@@ -1958,7 +2024,28 @@ function attachEventHandler(binding) {
1958
2024
 
1959
2025
  // EventToken は共有 pub/sub プリミティブ Token の薄い特化(element→state 方向)。
1960
2026
  // instanceof による型判別を成立させるため独立クラスとして維持する。
2027
+ //
2028
+ // ownerStateName は devtools 計装(protocol §4.5)のための内部 optional 引数。
2029
+ // event-token-protocol の外部仕様は不変更。
1961
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
+ }
1962
2049
  }
1963
2050
 
1964
2051
  const registryByStateElement$2 = new WeakMap();
@@ -1970,7 +2057,7 @@ function getOrCreateEventToken(stateElement, name) {
1970
2057
  }
1971
2058
  let token = registry.get(name);
1972
2059
  if (typeof token === "undefined") {
1973
- token = new EventToken(name);
2060
+ token = new EventToken(name, stateElement.name);
1974
2061
  registry.set(name, token);
1975
2062
  }
1976
2063
  return token;
@@ -2606,7 +2693,7 @@ function getUUID() {
2606
2693
  return `u${(count++).toString(36)}`;
2607
2694
  }
2608
2695
 
2609
- let version = 0;
2696
+ let version$1 = 0;
2610
2697
  class ListIndex {
2611
2698
  uuid = getUUID();
2612
2699
  parentListIndex;
@@ -2627,7 +2714,7 @@ class ListIndex {
2627
2714
  this.position = parentListIndex ? parentListIndex.position + 1 : 0;
2628
2715
  this.length = this.position + 1;
2629
2716
  this._index = index;
2630
- this._version = version;
2717
+ this._version = version$1;
2631
2718
  }
2632
2719
  /**
2633
2720
  * Gets current index value.
@@ -2644,7 +2731,7 @@ class ListIndex {
2644
2731
  */
2645
2732
  set index(value) {
2646
2733
  this._index = value;
2647
- this._version = ++version;
2734
+ this._version = ++version$1;
2648
2735
  this.indexes[this.position] = value;
2649
2736
  }
2650
2737
  /**
@@ -2683,7 +2770,7 @@ class ListIndex {
2683
2770
  else {
2684
2771
  if (typeof this._indexes === "undefined" || this.dirty) {
2685
2772
  this._indexes = [...this.parentListIndex.indexes, this._index];
2686
- this._version = version;
2773
+ this._version = version$1;
2687
2774
  }
2688
2775
  }
2689
2776
  return this._indexes;
@@ -3083,12 +3170,18 @@ function peekBindingSetByAbsoluteStateAddress(absoluteStateAddress) {
3083
3170
  function addBindingByAbsoluteStateAddress(absoluteStateAddress, binding) {
3084
3171
  const bindingSet = getBindingSetByAbsoluteStateAddress(absoluteStateAddress);
3085
3172
  bindingSet.add(binding);
3173
+ if (devtoolsSink !== null) {
3174
+ devtoolsSink({ type: "state:binding-added", absoluteAddress: absoluteStateAddress, binding });
3175
+ }
3086
3176
  }
3087
3177
  function removeBindingByAbsoluteStateAddress(absoluteStateAddress, binding) {
3088
3178
  // get-or-create を通すと未登録アドレスに空 Set を生成してしまうため素の get で参照する
3089
3179
  const bindingSet = bindingSetByAbsoluteStateAddress.get(absoluteStateAddress);
3090
3180
  if (bindingSet !== undefined) {
3091
3181
  bindingSet.delete(binding);
3182
+ if (devtoolsSink !== null) {
3183
+ devtoolsSink({ type: "state:binding-removed", absoluteAddress: absoluteStateAddress, binding });
3184
+ }
3092
3185
  }
3093
3186
  }
3094
3187
 
@@ -4780,840 +4873,1133 @@ async function buildBindings(root) {
4780
4873
  }
4781
4874
  }
4782
4875
 
4783
- // ハイドレーション時にスキップするバインディングタイプ
4784
- 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:(.+)$/;
4785
4887
  /**
4786
- * SSR ブロック境界コメントを走査して、start〜end 間のノードを収集する
4888
+ * script 要素へ埋め込む JSON を HTML パーサから保護する。
4889
+ * HTML 直列化時、script の中身は生のまま出力されるため、state 値に
4890
+ * "</script>" や "<!--" を含む文字列があると script を脱出できてしまう。
4891
+ * "<" ">" "&" と U+2028/U+2029 を JSON の \uXXXX エスケープへ置換する
4892
+ * (JSON.parse では元の文字列と等価に復元される)。
4787
4893
  */
4788
- function collectSsrBlocks(root) {
4789
- const blocks = [];
4790
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
4791
- const startComments = [];
4792
- // まず全コメントを収集
4793
- while (walker.nextNode()) {
4794
- 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';
4795
4908
  }
4796
- for (const comment of startComments) {
4797
- const startMatch = SSR_BLOCK_START.exec(comment.data);
4798
- if (!startMatch)
4799
- continue;
4800
- const type = startMatch[1];
4801
- const info = startMatch[2]; // "uuid:path:index" or "uuid:path"
4802
- const parts = info.split(':');
4803
- let uuid;
4804
- let path;
4805
- let index = null;
4806
- if (type === 'for') {
4807
- // uuid:path:index
4808
- uuid = parts[0];
4809
- path = parts[1];
4810
- 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();
4811
4915
  }
4812
- else {
4813
- // uuid:path
4814
- uuid = parts[0];
4815
- path = parts.slice(1).join(':');
4916
+ return this._stateData;
4917
+ }
4918
+ get templates() {
4919
+ if (this._templates === null) {
4920
+ this._templates = this._loadTemplates();
4816
4921
  }
4817
- // start と end の間のノードを収集
4818
- const nodes = [];
4819
- let sibling = comment.nextSibling;
4820
- const endPattern = `@@wcs-${type}-end:${info}`;
4821
- while (sibling) {
4822
- if (sibling.nodeType === Node.COMMENT_NODE && sibling.data === endPattern) {
4823
- break;
4824
- }
4825
- nodes.push(sibling);
4826
- sibling = sibling.nextSibling;
4922
+ return this._templates;
4923
+ }
4924
+ get hydrateProps() {
4925
+ if (this._hydrateProps === null) {
4926
+ this._hydrateProps = this._loadHydrateProps();
4827
4927
  }
4828
- blocks.push({ type, uuid, path, index, nodes });
4928
+ return this._hydrateProps;
4829
4929
  }
4830
- return blocks;
4831
- }
4832
- /**
4833
- * live DOM ノード群からバインディングを収集する。
4834
- * ノードを一時的に DocumentFragment に移動して collectNodesAndBindingInfos を実行し、
4835
- * 元の位置に戻す。
4836
- */
4837
- function collectBindingsFromLiveNodes(nodes) {
4838
- if (nodes.length === 0)
4839
- return { bindingInfos: [], subscriberNodes: [] };
4840
- // ノードの元の位置を記録
4841
- const parent = nodes[0].parentNode;
4842
- const nextSibling = nodes[nodes.length - 1].nextSibling;
4843
- // 一時的に wrapper 要素に移動(collectNodesAndBindingInfos は Element を受け付ける)
4844
- const wrapper = document.createElement('div');
4845
- for (const node of nodes) {
4846
- wrapper.appendChild(node);
4930
+ getTemplate(uuid) {
4931
+ return this.templates.get(uuid) ?? null;
4847
4932
  }
4848
- // バインディング収集
4849
- const [subscriberNodes, allBindings] = collectNodesAndBindingInfos(wrapper);
4850
- // _initializeBindings 相当の処理
4851
- for (const binding of allBindings) {
4852
- replaceToReplaceNode(binding);
4853
- if (attachEventHandler(binding))
4854
- continue;
4855
- if (attachEventTokenHandler(binding))
4856
- continue;
4857
- attachTwowayEventHandler(binding);
4858
- attachRadioEventHandler(binding);
4859
- 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];
4860
4946
  }
4861
- // 元の位置に戻す
4862
- if (parent) {
4863
- while (wrapper.firstChild) {
4864
- parent.insertBefore(wrapper.firstChild, nextSibling);
4865
- }
4947
+ setStateData(data) {
4948
+ this._stateData = data;
4866
4949
  }
4867
- return {
4868
- bindingInfos: allBindings,
4869
- subscriberNodes,
4870
- };
4871
- }
4872
- /**
4873
- * SSR ブロックの DOM ノードを Content 化し、バインディングを登録する。
4874
- */
4875
- function hydrateBlocks(root, blocks) {
4876
- // for ブロックの listIndex を UUID ごとに収集
4877
- const listIndexesByUuid = new Map();
4878
- for (const block of blocks) {
4879
- if (block.nodes.length === 0)
4880
- continue;
4881
- const content = createContentFromNodes(block.nodes);
4882
- // Content のバインディングを収集
4883
- const { bindingInfos, subscriberNodes } = collectBindingsFromLiveNodes(block.nodes);
4884
- // Content 内のノードに data-wcs-completed を付与
4885
- // (メインの collectNodesAndBindingInfos で重複登録されないようにする)
4886
- for (const node of subscriberNodes) {
4887
- if (node.nodeType === Node.ELEMENT_NODE) {
4888
- node.setAttribute('data-wcs-completed', '');
4889
- }
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 || '{}');
4890
4959
  }
4891
- setBindingsByContent(content, bindingInfos);
4892
- setNodesByContent(content, subscriberNodes);
4893
- const indexBindings = [];
4894
- for (const binding of bindingInfos) {
4895
- if (binding.statePathName in INDEX_BY_INDEX_NAME) {
4896
- 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);
4897
4971
  }
4898
4972
  }
4899
- setIndexBindingsByContent(content, indexBindings);
4900
- if (block.type === 'for' && block.index !== null) {
4901
- const placeholderComment = findPlaceholderComment(root, 'for', block.uuid);
4902
- if (placeholderComment) {
4903
- const listIndex = createListIndex(null, block.index);
4904
- hydrateSetContent(placeholderComment, listIndex, content);
4905
- const lastNode = block.nodes[block.nodes.length - 1];
4906
- hydrateSetLastNode(placeholderComment, lastNode);
4907
- setContentByNode(placeholderComment, content);
4908
- // ループコンテキストをバインドし、バインディングをアドレスに登録
4909
- const pathInfo = getPathInfo(block.path + '.' + WILDCARD);
4910
- const stateAddress = createStateAddress(pathInfo, listIndex);
4911
- // ILoopContext は IStateAddress + listIndex なので、stateAddress をそのまま使う
4912
- bindLoopContextToContent(content, stateAddress);
4913
- for (const binding of bindingInfos) {
4914
- const absAddr = getAbsoluteStateAddressByBinding(binding);
4915
- addBindingByAbsoluteStateAddress(absAddr, binding);
4916
- }
4917
- // listIndex を UUID ごとに収集(後で setListIndexesByList に渡す)
4918
- let indexes = listIndexesByUuid.get(block.uuid);
4919
- if (!indexes) {
4920
- indexes = [];
4921
- listIndexesByUuid.set(block.uuid, indexes);
4922
- }
4923
- 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;
4924
5013
  }
4925
5014
  }
4926
- else {
4927
- const placeholderComment = findPlaceholderComment(root, block.type, block.uuid);
4928
- if (placeholderComment) {
4929
- setContentByNode(placeholderComment, content);
4930
- // バインディングをアドレスに登録
4931
- for (const binding of bindingInfos) {
4932
- const absAddr = getAbsoluteStateAddressByBinding(binding);
4933
- 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;
4934
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);
4935
5063
  }
4936
5064
  }
5065
+ clearSsrPropertyStore();
4937
5066
  }
4938
- // for ブロックの listIndex を state のリスト値に紐づける
4939
- for (const [uuid, indexes] of listIndexesByUuid) {
4940
- const placeholderComment = findPlaceholderComment(root, 'for', uuid);
4941
- if (!placeholderComment)
4942
- continue;
4943
- // state から現在のリスト値を取得して listIndexes を設定
4944
- const rootNode = placeholderComment.getRootNode();
4945
- // structuralBindings はまだ登録前なので、getParseBindTextResults を直接使う
4946
- const fragmentInfo = getFragmentInfoByUUID(uuid);
4947
- if (!fragmentInfo)
4948
- continue;
4949
- const stateName = fragmentInfo.parseBindTextResult.stateName;
4950
- const statePathName = fragmentInfo.parseBindTextResult.statePathName;
4951
- const stateElement = getStateElementByName(rootNode, stateName);
4952
- if (!stateElement)
4953
- continue;
4954
- stateElement.createState("readonly", (state) => {
4955
- const list = state[statePathName];
4956
- if (Array.isArray(list)) {
4957
- 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);
4958
5077
  }
4959
- });
5078
+ }
5079
+ for (const comment of toRemove) {
5080
+ comment.remove();
5081
+ }
4960
5082
  }
4961
- }
4962
- function findPlaceholderComment(root, type, uuid) {
4963
- const keywordMap = {
4964
- 'for': config.commentForPrefix,
4965
- 'if': config.commentIfPrefix,
4966
- 'elseif': config.commentElseIfPrefix,
4967
- 'else': config.commentElsePrefix,
4968
- };
4969
- const keyword = keywordMap[type];
4970
- if (!keyword)
4971
- return null;
4972
- const pattern = `@@${keyword}:${uuid}`;
4973
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
4974
- while (walker.nextNode()) {
4975
- const comment = walker.currentNode;
4976
- if (comment.data === pattern) {
4977
- 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();
4978
5097
  }
4979
5098
  }
4980
- return null;
4981
- }
4982
- /**
4983
- * <wcs-ssr> 内のテンプレートを fragmentInfoByUUID に復帰させる。
4984
- */
4985
- function restoreFragments(root, ssrEl) {
4986
- const rootNode = root;
4987
- let lastIfParseResult = null;
4988
- for (const [uuid, tpl] of ssrEl.templates) {
4989
- const bindText = tpl.getAttribute(config.bindAttributeName) || '';
4990
- const parseBindTextResults = parseBindTextsForElement(bindText);
4991
- let parseBindTextResult = parseBindTextResults[0];
4992
- const bindingType = parseBindTextResult.bindingType;
4993
- // else: 直前の if 条件の not → 条件反転
4994
- // elseif: 独自条件を持つが stateName は if から引き継ぐ
4995
- if (bindingType === 'else' && lastIfParseResult) {
4996
- parseBindTextResult = {
4997
- ...lastIfParseResult,
4998
- outFilters: [...lastIfParseResult.outFilters, createNotFilter()],
4999
- bindingType: 'else',
5000
- };
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
+ }
5001
5113
  }
5002
- else if (bindingType === 'elseif' && lastIfParseResult) {
5003
- parseBindTextResult = {
5004
- ...parseBindTextResult,
5005
- stateName: lastIfParseResult.stateName,
5006
- };
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
+ }
5007
5129
  }
5008
- // if chain の追跡
5009
- if (bindingType === 'if') {
5010
- 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
+ }
5011
5154
  }
5012
- else if (bindingType === 'elseif') {
5013
- 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
+ }
5014
5163
  }
5015
- else if (bindingType === 'else') {
5016
- 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();
5017
5180
  }
5018
- const fragment = document.importNode(tpl.content, true);
5019
- const forPath = bindingType === "for" ? parseBindTextResult.statePathName : undefined;
5020
- optimizeFragment(fragment);
5021
- if (typeof forPath === "string") {
5022
- 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();
5023
5220
  }
5024
- collectStructuralFragments(rootNode, fragment, forPath);
5025
- const fragmentInfo = {
5026
- fragment,
5027
- parseBindTextResult,
5028
- nodeInfos: getFragmentNodeInfos(fragment),
5029
- };
5030
- setFragmentInfoByUUID(uuid, rootNode, fragmentInfo);
5031
5221
  }
5032
5222
  }
5223
+
5224
+ // ハイドレーション時にスキップするバインディングタイプ
5225
+ const STRUCTURAL_TYPES = new Set(['for', 'if', 'elseif', 'else']);
5033
5226
  /**
5034
- * SSR ハイドレーション用バインディング初期化。
5035
- * バージョン不一致時は DOM をクリーンアップして false を返す
5036
- * (呼び出し元で buildBindings にフォールバック)。
5227
+ * SSR ブロック境界コメントを走査して、start〜end 間のノードを収集する
5037
5228
  */
5038
- async function hydrateBindings(root) {
5039
- await waitForStateInitialize(root);
5040
- // バージョン検証
5041
- const ssrElements = root.querySelectorAll(config.tagNames.ssr);
5042
- for (const ssrNode of ssrElements) {
5043
- const ssrEl = ssrNode;
5044
- if (!ssrEl.verifyVersion()) {
5045
- console.warn(`[@wcstack/state] SSR version mismatch: server="${ssrEl.version}", client="${VERSION}". Falling back to full render.`);
5046
- Ssr.cleanupDom(root);
5047
- return false;
5048
- }
5049
- }
5050
- // <wcs-ssr> からテンプレートを fragmentInfoByUUID に復帰
5051
- for (const ssrNode of ssrElements) {
5052
- restoreFragments(root, ssrNode);
5053
- }
5054
- // SSR ブロック境界コメントから既存 DOM を Content 化
5055
- const blocks = collectSsrBlocks(document.body);
5056
- hydrateBlocks(document.body, blocks);
5057
- // ブロック境界コメント (start/end) を除去
5058
- Ssr.removeBlockBoundaryComments(document.body);
5059
- // <wcs-ssr> を一時除去(バインディング走査に含めない)
5060
- const ssrParents = [];
5061
- for (const el of ssrElements) {
5062
- if (el.parentNode) {
5063
- ssrParents.push({ el, parent: el.parentNode, next: el.nextSibling });
5064
- el.remove();
5065
- }
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);
5066
5236
  }
5067
- // 構造プレースホルダーコメント (@@wcs-for:uuid ) は残す
5068
- // バインディング走査で拾われ、状態変化時の再レンダリングに使われる
5069
- // SSR テキストバインディングを @@: 形式に復元
5070
- Ssr.restoreTextBindings(document.body);
5071
- // ノードとバインディングを収集
5072
- const [subscriberNodes, allBindings] = collectNodesAndBindingInfos(document.body);
5073
- // 収集完了したノードに data-wcs-completed 属性を付与
5074
- // for ブロック内ノード(hydrateBlocks で登録済み)にはループコンテキストをリセットしない
5075
- for (const node of subscriberNodes) {
5076
- if (node.nodeType === Node.ELEMENT_NODE) {
5077
- const el = node;
5078
- if (!el.hasAttribute('data-wcs-completed')) {
5079
- setLoopContextByNode(node, null);
5080
- el.setAttribute('data-wcs-completed', '');
5081
- }
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);
5082
5252
  }
5083
5253
  else {
5084
- // コメントノード等
5085
- setLoopContextByNode(node, null);
5254
+ // uuid:path
5255
+ uuid = parts[0];
5256
+ path = parts.slice(1).join(':');
5086
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;
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);
5087
5288
  }
5088
- // バインディングを構造系とそれ以外に分離
5089
- const normalBindings = [];
5090
- const structuralBindings = [];
5289
+ // バインディング収集
5290
+ const [subscriberNodes, allBindings] = collectNodesAndBindingInfos(wrapper);
5291
+ // _initializeBindings 相当の処理
5091
5292
  for (const binding of allBindings) {
5092
5293
  replaceToReplaceNode(binding);
5093
- if (attachEventHandler(binding)) {
5294
+ if (attachEventHandler(binding))
5094
5295
  continue;
5095
- }
5096
- if (attachEventTokenHandler(binding)) {
5296
+ if (attachEventTokenHandler(binding))
5097
5297
  continue;
5098
- }
5099
5298
  attachTwowayEventHandler(binding);
5100
5299
  attachRadioEventHandler(binding);
5101
5300
  attachCheckboxEventHandler(binding);
5102
- if (STRUCTURAL_TYPES.has(binding.bindingType)) {
5103
- structuralBindings.push(binding);
5104
- }
5105
- else if (binding.statePathName.includes(WILDCARD)) {
5106
- // for ブロック内のバインディング → Content のバインディングとして登録済み
5107
- continue;
5108
- }
5109
- else {
5110
- normalBindings.push(binding);
5301
+ }
5302
+ // 元の位置に戻す
5303
+ if (parent) {
5304
+ while (wrapper.firstChild) {
5305
+ parent.insertBefore(wrapper.firstChild, nextSibling);
5111
5306
  }
5112
5307
  }
5113
- // 全バインディング(通常 + 構造)をアドレスに登録
5114
- for (const binding of [...normalBindings, ...structuralBindings]) {
5115
- const absoluteStateAddress = getAbsoluteStateAddressByBinding(binding);
5116
- addBindingByAbsoluteStateAddress(absoluteStateAddress, binding);
5117
- const rootNode = binding.replaceNode.getRootNode();
5118
- const stateElement = getStateElementByName(rootNode, binding.stateName);
5119
- if (stateElement === null) {
5120
- 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
+ }
5121
5331
  }
5122
- if (binding.bindingType !== 'event') {
5123
- 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
+ }
5124
5339
  }
5125
- }
5126
- // for バインディングの lastListValue を初期値として設定
5127
- // (次回の状態変化時に差分計算の基準になる)
5128
- for (const binding of structuralBindings) {
5129
- if (binding.bindingType === 'for') {
5130
- const absAddr = getAbsoluteStateAddressByBinding(binding);
5131
- const rootNode = binding.replaceNode.getRootNode();
5132
- const stateElement = getStateElementByName(rootNode, binding.stateName);
5133
- if (stateElement) {
5134
- stateElement.createState("readonly", (state) => {
5135
- const value = state[binding.statePathName];
5136
- if (Array.isArray(value)) {
5137
- setLastListValueByAbsoluteStateAddress(absAddr, value);
5138
- }
5139
- });
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);
5140
5365
  }
5141
5366
  }
5142
- }
5143
- // 通常バインディングのみ初回値適用(構造バインディングはSSR描画済み)
5144
- applyChangeFromBindings(normalBindings);
5145
- // <wcs-ssr> を元に戻す
5146
- for (const { el, parent, next } of ssrParents) {
5147
- parent.insertBefore(el, next);
5148
- }
5149
- // hydrateProps 復元
5150
- const restoredSsrElements = root.querySelectorAll(config.tagNames.ssr);
5151
- for (const ssrNode of restoredSsrElements) {
5152
- const ssrEl = ssrNode;
5153
- const props = ssrEl.hydrateProps;
5154
- for (const [id, propMap] of Object.entries(props)) {
5155
- const target = root.querySelector(`[data-wcs-ssr-id="${id}"]`);
5156
- if (!target)
5157
- continue;
5158
- for (const [propName, value] of Object.entries(propMap)) {
5159
- 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
+ }
5160
5376
  }
5161
5377
  }
5162
5378
  }
5163
- // ハイドレーション中の重複登録防止用属性を除去
5164
- const completedEls = root.querySelectorAll('[data-wcs-completed]');
5165
- for (const el of completedEls) {
5166
- 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
+ });
5167
5401
  }
5168
- return true;
5169
5402
  }
5170
-
5171
- const stateElementByNameByNode = new WeakMap();
5172
- const bindingsReadyByNode = new WeakMap();
5173
- function getStateElementByName(rootNode, name) {
5174
- let stateElementByName = stateElementByNameByNode.get(rootNode);
5175
- 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)
5176
5412
  return null;
5177
- }
5178
- return stateElementByName.get(name) || null;
5179
- }
5180
- /**
5181
- * 指定された rootNode のバインディング初期化が完了するまで待機する Promise を返す。
5182
- */
5183
- function getBindingsReady(rootNode) {
5184
- return bindingsReadyByNode.get(rootNode) ?? Promise.resolve();
5185
- }
5186
- function setStateElementByName(rootNode, name, element) {
5187
- let stateElementByName = stateElementByNameByNode.get(rootNode);
5188
- if (element === null) {
5189
- // 削除の場合、Mapが存在しない場合は何もしない
5190
- if (!stateElementByName) {
5191
- return;
5192
- }
5193
- stateElementByName.delete(name);
5194
- if (stateElementByName.size === 0) {
5195
- stateElementByNameByNode.delete(rootNode);
5196
- }
5197
- if (config.debug) {
5198
- console.debug(`State element unregistered: name="${name}"`);
5199
- }
5200
- }
5201
- else {
5202
- // 登録の場合
5203
- if (!stateElementByName) {
5204
- stateElementByName = new Map();
5205
- stateElementByNameByNode.set(rootNode, stateElementByName);
5206
- // 初めてルートノードに登録する場合
5207
- // enable-ssr 属性があり、サーバーサイドでない場合はハイドレーション
5208
- const enableSsr = !inSsr() && element.hasAttribute?.('enable-ssr');
5209
- if (rootNode.constructor.name === 'HTMLDocument' || rootNode.constructor.name === 'Document') {
5210
- const ready = new Promise((resolve) => {
5211
- queueMicrotask(async () => {
5212
- if (enableSsr) {
5213
- const success = await hydrateBindings(rootNode);
5214
- if (!success) {
5215
- await buildBindings(rootNode);
5216
- }
5217
- }
5218
- else {
5219
- await buildBindings(rootNode);
5220
- }
5221
- resolve();
5222
- });
5223
- });
5224
- bindingsReadyByNode.set(rootNode, ready);
5225
- }
5226
- else if (rootNode.constructor.name === 'ShadowRoot') {
5227
- const ready = new Promise((resolve) => {
5228
- queueMicrotask(async () => {
5229
- await buildBindings(rootNode);
5230
- resolve();
5231
- });
5232
- });
5233
- bindingsReadyByNode.set(rootNode, ready);
5234
- }
5235
- }
5236
- if (stateElementByName.has(name)) {
5237
- raiseError(`State element with name "${name}" is already registered.`);
5238
- }
5239
- stateElementByName.set(name, element);
5240
- if (config.debug) {
5241
- 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;
5242
5419
  }
5243
5420
  }
5421
+ return null;
5244
5422
  }
5245
-
5246
- const fragmentInfoByUUID = new Map();
5247
- function setFragmentInfoByUUID(uuid, rootNode, fragmentInfo) {
5248
- if (fragmentInfo === null) {
5249
- fragmentInfoByUUID.delete(uuid);
5250
- }
5251
- else {
5252
- fragmentInfoByUUID.set(uuid, fragmentInfo);
5253
- const bindingPartial = fragmentInfo.parseBindTextResult;
5254
- const stateElement = getStateElementByName(rootNode, bindingPartial.stateName);
5255
- if (stateElement === null) {
5256
- 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
+ };
5257
5442
  }
5258
- stateElement.setPathInfo(bindingPartial.statePathName, bindingPartial.bindingType);
5259
- for (const nodeInfo of fragmentInfo.nodeInfos) {
5260
- for (const nodeBindingPartial of nodeInfo.parseBindTextResults) {
5261
- const nodeStateElement = getStateElementByName(rootNode, nodeBindingPartial.stateName);
5262
- if (nodeStateElement === null) {
5263
- raiseError(`State element with name "${nodeBindingPartial.stateName}" not found for fragment info node.`);
5264
- }
5265
- nodeStateElement.setPathInfo(nodeBindingPartial.statePathName, nodeBindingPartial.bindingType);
5266
- }
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);
5267
5464
  }
5465
+ collectStructuralFragments(rootNode, fragment, forPath);
5466
+ const fragmentInfo = {
5467
+ fragment,
5468
+ parseBindTextResult,
5469
+ nodeInfos: getFragmentNodeInfos(fragment),
5470
+ };
5471
+ setFragmentInfoByUUID(uuid, rootNode, fragmentInfo);
5268
5472
  }
5269
5473
  }
5270
- function getFragmentInfoByUUID(uuid) {
5271
- return fragmentInfoByUUID.get(uuid) || null;
5272
- }
5273
- function getAllFragmentUUIDs() {
5274
- return Array.from(fragmentInfoByUUID.keys());
5275
- }
5276
-
5277
- // SSR コメントパターン
5278
- const SSR_PLACEHOLDER_COMMENT = /^@@wcs-(?:for|if|elseif|else):[^-]/;
5279
- const SSR_BLOCK_START = /^@@wcs-(for|if|elseif|else)-start:(.+)$/;
5280
- const SSR_BLOCK_END = /^@@wcs-(for|if|elseif|else)-end:(.+)$/;
5281
- const SSR_TEXT_START = /^@@wcs-text-start:(.+)$/;
5282
5474
  /**
5283
- * script 要素へ埋め込む JSON を HTML パーサから保護する。
5284
- * HTML 直列化時、script の中身は生のまま出力されるため、state 値に
5285
- * "</script>" "<!--" を含む文字列があると script を脱出できてしまう。
5286
- * "<" ">" "&" と U+2028/U+2029 を JSON の \uXXXX エスケープへ置換する
5287
- * (JSON.parse では元の文字列と等価に復元される)。
5475
+ * SSR ハイドレーション用バインディング初期化。
5476
+ * バージョン不一致時は DOM をクリーンアップして false を返す
5477
+ * (呼び出し元で buildBindings にフォールバック)。
5288
5478
  */
5289
- function escapeJsonForScript(json) {
5290
- return json
5291
- .replace(/</g, '\\u003c')
5292
- .replace(/>/g, '\\u003e')
5293
- .replace(/&/g, '\\u0026')
5294
- .replace(/\u2028/g, '\\u2028')
5295
- .replace(/\u2029/g, '\\u2029');
5296
- }
5297
- class Ssr extends HTMLElement {
5298
- _stateData = null;
5299
- _templates = null;
5300
- _hydrateProps = null;
5301
- get name() {
5302
- 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
+ }
5303
5490
  }
5304
- get version() {
5305
- return this.getAttribute('version') || '';
5491
+ // <wcs-ssr> からテンプレートを fragmentInfoByUUID に復帰
5492
+ for (const ssrNode of ssrElements) {
5493
+ restoreFragments(root, ssrNode);
5306
5494
  }
5307
- get stateData() {
5308
- if (this._stateData === null) {
5309
- 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();
5310
5506
  }
5311
- return this._stateData;
5312
5507
  }
5313
- get templates() {
5314
- if (this._templates === null) {
5315
- 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
+ }
5316
5523
  }
5317
- return this._templates;
5318
- }
5319
- get hydrateProps() {
5320
- if (this._hydrateProps === null) {
5321
- this._hydrateProps = this._loadHydrateProps();
5524
+ else {
5525
+ // コメントノード等
5526
+ setLoopContextByNode(node, null);
5322
5527
  }
5323
- return this._hydrateProps;
5324
- }
5325
- getTemplate(uuid) {
5326
- return this.templates.get(uuid) ?? null;
5327
- }
5328
- /**
5329
- * サーバーの SSR バージョンとクライアントの state バージョンを検証する。
5330
- * メジャー・マイナーバージョンが一致すればtrue。
5331
- * version 属性がない場合は検証スキップ(true)。
5332
- */
5333
- verifyVersion() {
5334
- const serverVersion = this.version;
5335
- if (!serverVersion)
5336
- return true;
5337
- const serverParts = serverVersion.split('.');
5338
- const clientParts = VERSION.split('.');
5339
- // メジャー・マイナーが一致すれば互換
5340
- return serverParts[0] === clientParts[0] && serverParts[1] === clientParts[1];
5341
- }
5342
- setStateData(data) {
5343
- this._stateData = data;
5344
- }
5345
- setHydrateProps(props) {
5346
- this._hydrateProps = props;
5347
5528
  }
5348
- _loadStateData() {
5349
- const script = this.querySelector(`script[type="application/json"]:not([data-wcs-ssr-props])`);
5350
- if (!script)
5351
- return {};
5352
- try {
5353
- 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;
5354
5536
  }
5355
- catch {
5356
- return {};
5537
+ if (attachEventTokenHandler(binding)) {
5538
+ continue;
5357
5539
  }
5358
- }
5359
- _loadTemplates() {
5360
- const map = new Map();
5361
- const templates = this.querySelectorAll('template[id]');
5362
- for (const tpl of templates) {
5363
- const id = tpl.getAttribute('id');
5364
- if (id) {
5365
- map.set(id, tpl);
5366
- }
5540
+ attachTwowayEventHandler(binding);
5541
+ attachRadioEventHandler(binding);
5542
+ attachCheckboxEventHandler(binding);
5543
+ if (STRUCTURAL_TYPES.has(binding.bindingType)) {
5544
+ structuralBindings.push(binding);
5367
5545
  }
5368
- return map;
5369
- }
5370
- _loadHydrateProps() {
5371
- const script = this.querySelector('script[data-wcs-ssr-props]');
5372
- if (!script)
5373
- return {};
5374
- try {
5375
- return JSON.parse(script.textContent || '{}');
5546
+ else if (binding.statePathName.includes(WILDCARD)) {
5547
+ // for ブロック内のバインディング → Content のバインディングとして登録済み
5548
+ continue;
5376
5549
  }
5377
- catch {
5378
- return {};
5550
+ else {
5551
+ normalBindings.push(binding);
5379
5552
  }
5380
5553
  }
5381
- static findByName(root, name) {
5382
- const tagName = config.tagNames.ssr;
5383
- const parentEl = root instanceof Element
5384
- ? root
5385
- : root instanceof Document
5386
- ? root.documentElement
5387
- : null;
5388
- if (!parentEl)
5389
- return null;
5390
- const el = parentEl.querySelector(`${tagName}[name="${name}"]`);
5391
- 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
+ }
5392
5566
  }
5393
- /**
5394
- * stateData と構造テンプレート・プロパティから <wcs-ssr> の中身を構築する。
5395
- * server パッケージの renderToString から呼ばれる。
5396
- */
5397
- /**
5398
- * wcs-state 要素から $ プレフィックスや関数を除いたデータを抽出する。
5399
- */
5400
- static extractStateData(stateEl) {
5401
- const raw = stateEl.__state;
5402
- if (!raw || typeof raw !== 'object')
5403
- return {};
5404
- const data = {};
5405
- for (const [key, value] of Object.entries(raw)) {
5406
- if (!key.startsWith('$') && typeof value !== 'function') {
5407
- 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
+ });
5408
5581
  }
5409
5582
  }
5410
- return data;
5411
5583
  }
5412
- static buildContent(ssrEl, stateData) {
5413
- // 初期データ JSON
5414
- const jsonScript = document.createElement('script');
5415
- jsonScript.setAttribute('type', 'application/json');
5416
- jsonScript.textContent = escapeJsonForScript(JSON.stringify(stateData));
5417
- ssrEl.appendChild(jsonScript);
5418
- // UUID で管理されているテンプレートを復元して格納
5419
- const uuids = getAllFragmentUUIDs();
5420
- for (const uuid of uuids) {
5421
- const fragmentInfo = getFragmentInfoByUUID(uuid);
5422
- 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)
5423
5598
  continue;
5424
- const tpl = document.createElement('template');
5425
- tpl.setAttribute('id', uuid);
5426
- const bindResult = fragmentInfo.parseBindTextResult;
5427
- const bindText = bindResult.bindingType === 'else'
5428
- ? 'else:'
5429
- : `${bindResult.bindingType}: ${bindResult.statePathName}`;
5430
- tpl.setAttribute(config.bindAttributeName, bindText);
5431
- const content = fragmentInfo.fragment.cloneNode(true);
5432
- tpl.content.appendChild(content);
5433
- ssrEl.appendChild(tpl);
5434
- }
5435
- // 属性で代替不可なプロパティをハイドレーション用に格納
5436
- const ssrNodes = getAllSsrPropertyNodes();
5437
- if (ssrNodes.length > 0) {
5438
- const propsData = {};
5439
- for (let i = 0; i < ssrNodes.length; i++) {
5440
- const node = ssrNodes[i];
5441
- const entries = getSsrProperties(node);
5442
- if (entries.length === 0)
5443
- continue;
5444
- const id = `wcs-ssr-${i}`;
5445
- node.setAttribute('data-wcs-ssr-id', id);
5446
- const props = {};
5447
- for (const entry of entries) {
5448
- props[entry.propName] = entry.value;
5449
- }
5450
- propsData[id] = props;
5451
- }
5452
- if (Object.keys(propsData).length > 0) {
5453
- const propsScript = document.createElement('script');
5454
- propsScript.setAttribute('type', 'application/json');
5455
- propsScript.setAttribute('data-wcs-ssr-props', '');
5456
- propsScript.textContent = escapeJsonForScript(JSON.stringify(propsData));
5457
- ssrEl.appendChild(propsScript);
5599
+ for (const [propName, value] of Object.entries(propMap)) {
5600
+ target[propName] = value;
5458
5601
  }
5459
5602
  }
5460
- clearSsrPropertyStore();
5461
5603
  }
5462
- /**
5463
- * SSR ブロック境界コメント (@@wcs-*-start/end) を除去する
5464
- */
5465
- static removeBlockBoundaryComments(root) {
5466
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
5467
- const toRemove = [];
5468
- while (walker.nextNode()) {
5469
- const comment = walker.currentNode;
5470
- if (SSR_BLOCK_START.test(comment.data) || SSR_BLOCK_END.test(comment.data)) {
5471
- 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 });
5472
5650
  }
5473
5651
  }
5474
- for (const comment of toRemove) {
5475
- comment.remove();
5652
+ if (config.debug) {
5653
+ console.debug(`State element unregistered: name="${name}"`);
5476
5654
  }
5477
5655
  }
5478
- /**
5479
- * SSR の構造プレースホルダーコメント (@@wcs-for:uuid 等) を除去する
5480
- */
5481
- static removeStructuralComments(root) {
5482
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
5483
- const toRemove = [];
5484
- while (walker.nextNode()) {
5485
- const comment = walker.currentNode;
5486
- if (SSR_PLACEHOLDER_COMMENT.test(comment.data)) {
5487
- 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);
5488
5689
  }
5489
5690
  }
5490
- for (const comment of toRemove) {
5491
- 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);
5492
5701
  }
5493
5702
  }
5494
- /**
5495
- * SSR テキストバインディングコメントを復元する。
5496
- * <!--@@wcs-text-start:path-->text<!--@@wcs-text-end:path-->
5497
- * → <!--@@: path--> (バインディングシステムが認識する形式)
5498
- */
5499
- static restoreTextBindings(root) {
5500
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
5501
- const startComments = [];
5502
- while (walker.nextNode()) {
5503
- const comment = walker.currentNode;
5504
- const match = SSR_TEXT_START.exec(comment.data);
5505
- if (match) {
5506
- 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;
5507
5759
  }
5508
- }
5509
- for (const { comment, path } of startComments) {
5510
- const bindComment = document.createComment(`@@: ${path}`);
5511
- comment.parentNode.insertBefore(bindComment, comment);
5512
- let sibling = comment.nextSibling;
5513
- comment.remove();
5514
- const endPattern = `@@wcs-text-end:${path}`;
5515
- while (sibling) {
5516
- const next = sibling.nextSibling;
5517
- if (sibling.nodeType === Node.COMMENT_NODE && sibling.data === endPattern) {
5518
- sibling.parentNode.removeChild(sibling);
5519
- break;
5760
+ for (const binding of bindings) {
5761
+ if (binding.replaceNode.isConnected === false) {
5762
+ // 切断されているバインディングは無視
5763
+ continue;
5520
5764
  }
5521
- sibling.parentNode.removeChild(sibling);
5522
- sibling = next;
5765
+ processBindings.push(binding);
5523
5766
  }
5524
5767
  }
5768
+ applyChangeFromBindings(processBindings);
5769
+ // drain 終了フック: binding 適用後に dedup 済みバッチを通知する(設計書 §3-2)。
5770
+ // testApplyChange も同じ _applyChange を通るため、テストから同期に駆動できる。
5771
+ notifyUpdateBatchListeners(absoluteAddressSet);
5525
5772
  }
5526
- /**
5527
- * SSR DOM をクリーンアップし、buildBindings が動作できる状態に戻す。
5528
- * バージョン不一致時のフォールバック用。
5529
- *
5530
- * 1. SSR ブロック境界コメント間のレンダリング済みノードを除去
5531
- * 2. SSR テキストバインディングを @@: 形式に復元
5532
- * 3. プレースホルダーコメントを <wcs-ssr> 内のテンプレートで差し替え
5533
- * 4. data-wcs-ssr-id 属性を除去
5534
- * 5. <wcs-ssr> を除去
5535
- */
5536
- static cleanupDom(root) {
5537
- const body = document.body;
5538
- // <wcs-ssr> からテンプレート UUID マップを構築(カスタム要素未定義でも動作するよう DOM 直接走査)
5539
- const ssrElements = root.querySelectorAll(config.tagNames.ssr);
5540
- const templateByUuid = new Map();
5541
- for (const ssrNode of ssrElements) {
5542
- const templates = ssrNode.querySelectorAll('template[id]');
5543
- for (const tpl of templates) {
5544
- const id = tpl.getAttribute('id');
5545
- if (id) {
5546
- templateByUuid.set(id, tpl);
5547
- }
5548
- }
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;
5549
5816
  }
5550
- // SSR ブロック境界コメント間のレンダリング済みノードと境界コメントを除去
5551
- const walker1 = document.createTreeWalker(body, NodeFilter.SHOW_COMMENT);
5552
- const startComments = [];
5553
- while (walker1.nextNode()) {
5554
- const comment = walker1.currentNode;
5555
- if (SSR_BLOCK_START.test(comment.data)) {
5556
- startComments.push(comment);
5817
+ const sourceId = source.id;
5818
+ source._setSink((event) => {
5819
+ for (const listener of listeners) {
5820
+ listener.onEvent?.(sourceId, event);
5557
5821
  }
5558
- }
5559
- for (const startComment of startComments) {
5560
- const match = SSR_BLOCK_START.exec(startComment.data);
5561
- const type = match[1];
5562
- const info = match[2];
5563
- const endPattern = `@@wcs-${type}-end:${info}`;
5564
- let sibling = startComment.nextSibling;
5565
- while (sibling) {
5566
- const next = sibling.nextSibling;
5567
- if (sibling.nodeType === Node.COMMENT_NODE && sibling.data === endPattern) {
5568
- sibling.remove();
5569
- break;
5570
- }
5571
- sibling.remove();
5572
- sibling = next;
5822
+ });
5823
+ };
5824
+ return {
5825
+ version: DEVTOOLS_PROTOCOL_VERSION,
5826
+ sources,
5827
+ register(source) {
5828
+ if (sources.has(source.id)) {
5829
+ return;
5573
5830
  }
5574
- startComment.remove();
5575
- }
5576
- // SSR テキストバインディングを @@: 形式に復元
5577
- Ssr.restoreTextBindings(body);
5578
- // プレースホルダーコメント (@@wcs-for:uuid 等) をテンプレートに差し替え
5579
- const walker2 = document.createTreeWalker(body, NodeFilter.SHOW_COMMENT);
5580
- const placeholders = [];
5581
- while (walker2.nextNode()) {
5582
- const comment = walker2.currentNode;
5583
- if (SSR_PLACEHOLDER_COMMENT.test(comment.data)) {
5584
- const uuid = comment.data.split(':')[1];
5585
- placeholders.push({ comment, uuid });
5831
+ sources.set(source.id, source);
5832
+ applySink(source);
5833
+ for (const listener of listeners) {
5834
+ listener.onSourceRegistered?.(source);
5586
5835
  }
5587
- }
5588
- for (const { comment, uuid } of placeholders) {
5589
- const tpl = templateByUuid.get(uuid);
5590
- if (tpl) {
5591
- const restored = document.createElement('template');
5592
- const bindAttr = tpl.getAttribute(config.bindAttributeName);
5593
- if (bindAttr)
5594
- restored.setAttribute(config.bindAttributeName, bindAttr);
5595
- const imported = document.importNode(tpl.content, true);
5596
- if (imported.childNodes.length > 0) {
5597
- 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;
5598
5858
  }
5599
- else {
5600
- for (const child of Array.from(tpl.childNodes)) {
5601
- restored.content.appendChild(document.importNode(child, true));
5602
- }
5859
+ for (const source of sources.values()) {
5860
+ applySink(source);
5603
5861
  }
5604
- comment.parentNode.replaceChild(restored, comment);
5605
- }
5606
- }
5607
- // data-wcs-ssr-id 属性を除去
5608
- const ssrIdElements = root.querySelectorAll('[data-wcs-ssr-id]');
5609
- for (const el of ssrIdElements) {
5610
- el.removeAttribute('data-wcs-ssr-id');
5611
- }
5612
- // <wcs-ssr> を除去
5613
- for (const el of ssrElements) {
5614
- 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).`);
5615
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;
5616
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);
5617
6003
  }
5618
6004
 
5619
6005
  async function loadFromInnerScript(script, name) {
@@ -5781,7 +6167,7 @@ function getOrCreateCommandToken(stateElement, name) {
5781
6167
  }
5782
6168
  let token = registry.get(name);
5783
6169
  if (typeof token === "undefined") {
5784
- token = new CommandToken(name);
6170
+ token = new CommandToken(name, stateElement.name);
5785
6171
  registry.set(name, token);
5786
6172
  }
5787
6173
  return token;
@@ -6301,74 +6687,6 @@ function clearStreamNamespace(stateElement) {
6301
6687
  errorNamespaceByStateElement.delete(stateElement);
6302
6688
  }
6303
6689
 
6304
- const updateBatchListeners = new Set();
6305
- /**
6306
- * drain 終了リスナーを登録する。
6307
- */
6308
- function registerUpdateBatchListener(listener) {
6309
- updateBatchListeners.add(listener);
6310
- }
6311
- /**
6312
- * 全リスナーに drain のバッチを通知する。
6313
- * リスナーの throw は握りつぶさない(内部バグの隠蔽防止)。
6314
- * stream 側リスナーが entry ごとに自前で try/catch する契約(設計書 §3-2)。
6315
- */
6316
- function notifyUpdateBatchListeners(batch) {
6317
- for (const listener of updateBatchListeners) {
6318
- listener(batch);
6319
- }
6320
- }
6321
- class Updater {
6322
- _queueAbsoluteAddresses = [];
6323
- constructor() {
6324
- }
6325
- enqueueAbsoluteAddress(absoluteAddress) {
6326
- const requireStartProcess = this._queueAbsoluteAddresses.length === 0;
6327
- this._queueAbsoluteAddresses.push(absoluteAddress);
6328
- if (requireStartProcess) {
6329
- queueMicrotask(() => {
6330
- const absoluteAddresses = this._queueAbsoluteAddresses;
6331
- this._queueAbsoluteAddresses = [];
6332
- this._applyChange(absoluteAddresses);
6333
- });
6334
- }
6335
- }
6336
- // テスト用に公開
6337
- testApplyChange(absoluteAddresses) {
6338
- this._applyChange(absoluteAddresses);
6339
- }
6340
- _applyChange(absoluteAddresses) {
6341
- // Note: AbsoluteStateAddress はキャッシュされているため、
6342
- // 同一の (stateName, address) は同じインスタンスとなり、
6343
- // Set による重複排除が正しく機能する
6344
- const absoluteAddressSet = new Set(absoluteAddresses);
6345
- const processBindings = [];
6346
- for (const absoluteAddress of absoluteAddressSet) {
6347
- // peek: バインディングの無いアドレス(リスト置換で enqueue される中間
6348
- // アドレス等)に空 Set を生成・蓄積しない
6349
- const bindings = peekBindingSetByAbsoluteStateAddress(absoluteAddress);
6350
- if (bindings === undefined) {
6351
- continue;
6352
- }
6353
- for (const binding of bindings) {
6354
- if (binding.replaceNode.isConnected === false) {
6355
- // 切断されているバインディングは無視
6356
- continue;
6357
- }
6358
- processBindings.push(binding);
6359
- }
6360
- }
6361
- applyChangeFromBindings(processBindings);
6362
- // drain 終了フック: binding 適用後に dedup 済みバッチを通知する(設計書 §3-2)。
6363
- // testApplyChange も同じ _applyChange を通るため、テストから同期に駆動できる。
6364
- notifyUpdateBatchListeners(absoluteAddressSet);
6365
- }
6366
- }
6367
- const updater = new Updater();
6368
- function getUpdater() {
6369
- return updater;
6370
- }
6371
-
6372
6690
  /**
6373
6691
  * stream/argsTrace.ts
6374
6692
  *
@@ -7658,11 +7976,17 @@ function setByAddress(target, address, value, receiver, handler) {
7658
7976
  // primitive 値かつ Object.is 同値なら、set / enqueue / walkDependency / DOM 適用 /
7659
7977
  // $updatedCallback / DCC イベントを丸ごとスキップ(標準的なリアクティブ no-op)。
7660
7978
  // 参照型(object/array)は in-place mutation 取りこぼし防止のため素通し(ガードしない)。
7979
+ // devtools write イベント用: guard が既に取得した旧値のみ流用する
7980
+ // (参照型のために追加の get はしない — protocol §4.2)
7981
+ let devOldValue;
7982
+ let devHasOldValue = false;
7661
7983
  if (config.sameValueGuard && (value === null || typeof value !== "object")) {
7662
7984
  const oldValue = getByAddress(target, address, receiver, handler);
7663
7985
  if (Object.is(oldValue, value)) {
7664
7986
  return true;
7665
7987
  }
7988
+ devOldValue = oldValue;
7989
+ devHasOldValue = true;
7666
7990
  }
7667
7991
  // --- end same-value guard ---
7668
7992
  const isSwappable = stateElement.elementPaths.has(address.pathInfo.path);
@@ -7670,6 +7994,15 @@ function setByAddress(target, address, value, receiver, handler) {
7670
7994
  stateElement.getterPaths.has(address.pathInfo.path);
7671
7995
  const absPathInfo = getAbsolutePathInfo(stateElement, address.pathInfo);
7672
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
+ }
7673
8006
  try {
7674
8007
  if (isSwappable) {
7675
8008
  return _setByAddressWithSwap(target, address, absAddress, value, receiver, handler);
@@ -8044,15 +8377,16 @@ async function setLoopContextAsync(handler, loopContext, callback) {
8044
8377
  * StateClassのProxyトラップとして、プロパティアクセス時の値取得処理を担う関数(get)の実装です。
8045
8378
  *
8046
8379
  * 主な役割:
8047
- * - 文字列プロパティの場合、特殊プロパティ($1〜$9, $resolve, $getAll, $navigate)に応じた値やAPIを返却
8380
+ * - 文字列プロパティの場合、特殊プロパティ($1〜、$stateElement, $getAll, $postUpdate,
8381
+ * $resolve, $trackDependency, $command, $streamStatus, $streamError)に応じた値やAPIを返却
8048
8382
  * - 通常のプロパティはgetResolvedPathInfoでパス情報を解決し、getListIndexでリストインデックスを取得
8049
8383
  * - getByRefで構造化パス・リストインデックスに対応した値を取得
8050
8384
  * - シンボルプロパティの場合はhandler.callableApi経由でAPIを呼び出し
8051
8385
  * - それ以外はReflect.getで通常のプロパティアクセスを実行
8052
8386
  *
8053
8387
  * 設計ポイント:
8054
- * - $1〜$9は直近のStatePropertyRefのリストインデックス値を返す特殊プロパティ
8055
- * - $resolve, $getAll, $navigateはAPI関数やルーターインスタンスを返す
8388
+ * - $1〜$128(MAX_WILDCARD_DEPTH)は直近のStatePropertyRefのリストインデックス値を返す特殊プロパティ
8389
+ * - $getAll, $resolve 等はAPI関数を、$command / $streamStatus / $streamError は名前空間を返す
8056
8390
  * - 通常のプロパティアクセスもバインディングや多重ループに対応
8057
8391
  * - シンボルAPIやReflect.getで拡張性・互換性も確保
8058
8392
  */
@@ -9225,6 +9559,8 @@ function bootstrapState(config) {
9225
9559
  setConfig(config);
9226
9560
  }
9227
9561
  registerComponents();
9562
+ // DevTools Hook Protocol への source 登録(SSR では no-op・冪等)
9563
+ registerDevtoolsSource();
9228
9564
  }
9229
9565
 
9230
9566
  /**
@@ -9424,6 +9760,9 @@ function getWcsManifest() {
9424
9760
  STATE_COMMAND_NAMESPACE_NAME,
9425
9761
  STATE_EVENT_TOKENS_NAME,
9426
9762
  STATE_ON_NAME,
9763
+ STATE_STREAMS_NAME,
9764
+ STATE_STREAM_STATUS_NAMESPACE_NAME,
9765
+ STATE_STREAM_ERROR_NAMESPACE_NAME,
9427
9766
  ],
9428
9767
  };
9429
9768
  }