@wcstack/state 1.8.6 → 1.9.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
@@ -59,7 +59,7 @@ function setConfig(partialConfig) {
59
59
  }
60
60
  }
61
61
 
62
- var version$1 = "1.8.6";
62
+ var version$1 = "1.9.0";
63
63
  var pkg = {
64
64
  version: version$1};
65
65
 
@@ -187,6 +187,8 @@ const STATE_DISCONNECTED_CALLBACK_NAME = "$disconnectedCallback";
187
187
  const STATE_UPDATED_CALLBACK_NAME = "$updatedCallback";
188
188
  const WEBCOMPONENT_STATE_READY_CALLBACK_NAME = "$stateReadyCallback";
189
189
  const STATE_BINDABLES_NAME = "$bindables";
190
+ const STATE_COMMAND_TOKENS_NAME = "$commandTokens";
191
+ const STATE_COMMAND_NAMESPACE_NAME = "$command";
190
192
  const DCC_DEFINITION_ATTRIBUTE = "data-wc-definition";
191
193
 
192
194
  const _cache$4 = new Map();
@@ -1988,6 +1990,123 @@ function applyChangeToClass(binding, _context, newValue) {
1988
1990
  element.classList.toggle(className, newValue);
1989
1991
  }
1990
1992
 
1993
+ // _subscribers は Set のため挿入順を保持する。
1994
+ // emit() は subscribe() された順に呼び出され、戻り値配列も同じ順序で返る。
1995
+ class CommandToken {
1996
+ _name;
1997
+ _subscribers = new Set();
1998
+ constructor(name) {
1999
+ this._name = name;
2000
+ }
2001
+ get name() {
2002
+ return this._name;
2003
+ }
2004
+ get size() {
2005
+ return this._subscribers.size;
2006
+ }
2007
+ subscribe(fn) {
2008
+ this._subscribers.add(fn);
2009
+ return () => {
2010
+ this._subscribers.delete(fn);
2011
+ };
2012
+ }
2013
+ unsubscribe(fn) {
2014
+ return this._subscribers.delete(fn);
2015
+ }
2016
+ emit(...args) {
2017
+ const results = [];
2018
+ for (const fn of this._subscribers) {
2019
+ results.push(fn(...args));
2020
+ }
2021
+ return results;
2022
+ }
2023
+ }
2024
+ function isCommandToken(value) {
2025
+ return value instanceof CommandToken;
2026
+ }
2027
+
2028
+ /**
2029
+ * command.<methodName>: <commandToken-path> バインディングの適用ハンドラ。
2030
+ *
2031
+ * subscribe lifecycle:
2032
+ * - 同一 binding に同じ token が再評価された場合は no-op。
2033
+ * - 異なる token が来た場合は古い subscription を解除し、新しい token に subscribe し直す。
2034
+ * 旧解除は新しい binding 妥当性検証(methodName・wcBindable.commands チェック)を
2035
+ * 通過した後に行うため、再評価が validation で失敗しても旧購読は温存される(fail-fast)。
2036
+ * - element は WeakRef で保持し、subscriber 経由で element を強参照しないようにする。
2037
+ * これにより、element が DOM から消えた後に subscriber が token._subscribers に
2038
+ * 残っていても element 本体は GC 可能。
2039
+ * - emit 時に下記いずれかなら自動で subscription を破棄する(lazy purge):
2040
+ * - WeakRef.deref() が undefined(element が既に GC 済み)
2041
+ * - element.isConnected が false(DOM から取り外されている)
2042
+ *
2043
+ * 既知の制約:
2044
+ * - emit が来なければ stale subscriber は token に残り続ける(要素が GC されても subscriber 関数自体は残る)。
2045
+ * state インスタンスが disconnect されたタイミングで registry ごとクリアされるため、最終的には解放される。
2046
+ * element ライフサイクルに直接フックする手段が現状の binding 機構に無いため、能動的な purge は将来課題。
2047
+ */
2048
+ const subscribedBindings = new WeakMap();
2049
+ function getWcBindable(element) {
2050
+ const customTagName = getCustomElement(element);
2051
+ if (customTagName === null) {
2052
+ return null;
2053
+ }
2054
+ const customClass = customElements.get(customTagName);
2055
+ if (typeof customClass === "undefined") {
2056
+ raiseError(`Custom element <${customTagName}> is not defined for command binding.`);
2057
+ }
2058
+ const bindable = customClass.wcBindable;
2059
+ if (bindable?.protocol === "wc-bindable" && bindable?.version === 1) {
2060
+ return bindable;
2061
+ }
2062
+ return null;
2063
+ }
2064
+ function applyChangeToCommand(binding, _context, newValue) {
2065
+ if (!isCommandToken(newValue)) {
2066
+ raiseError(`command binding requires a CommandToken value (use $commandToken or $commandTokens declaration).`);
2067
+ }
2068
+ const token = newValue;
2069
+ const existing = subscribedBindings.get(binding);
2070
+ if (existing && existing.token === token) {
2071
+ return;
2072
+ }
2073
+ // 新しい binding 妥当性検証は、旧 subscription を解除する前に通す(fail-fast)。
2074
+ const element = binding.node;
2075
+ const methodName = binding.propSegments[1];
2076
+ if (typeof methodName !== "string" || methodName.length === 0) {
2077
+ raiseError(`command binding requires a method name (e.g., "command.fetch").`);
2078
+ }
2079
+ const bindable = getWcBindable(element);
2080
+ if (bindable === null) {
2081
+ raiseError(`command binding requires a wc-bindable custom element. <${element.tagName.toLowerCase()}> is not wc-bindable.`);
2082
+ }
2083
+ if (!Array.isArray(bindable.commands) || !bindable.commands.includes(methodName)) {
2084
+ raiseError(`Command "${methodName}" is not declared in wcBindable.commands of <${element.tagName.toLowerCase()}>.`);
2085
+ }
2086
+ // ここまで来たら旧解除して新 subscribe に切り替える。
2087
+ if (existing) {
2088
+ existing.unsubscribe();
2089
+ subscribedBindings.delete(binding);
2090
+ }
2091
+ const elementRef = new WeakRef(element);
2092
+ let unsubscribe = null;
2093
+ const subscriber = (...args) => {
2094
+ const el = elementRef.deref();
2095
+ if (!el || !el.isConnected) {
2096
+ unsubscribe?.();
2097
+ subscribedBindings.delete(binding);
2098
+ return undefined;
2099
+ }
2100
+ const method = el[methodName];
2101
+ if (typeof method !== "function") {
2102
+ raiseError(`Method "${methodName}" is not a function on <${el.tagName.toLowerCase()}>.`);
2103
+ }
2104
+ return Reflect.apply(method, el, args);
2105
+ };
2106
+ unsubscribe = token.subscribe(subscriber);
2107
+ subscribedBindings.set(binding, { token, unsubscribe, elementRef });
2108
+ }
2109
+
1991
2110
  const _cache$1 = new WeakMap();
1992
2111
  const _cacheNullListIndex = new WeakMap();
1993
2112
  class StateAddress {
@@ -3088,6 +3207,7 @@ const applyChangeByFirstSegment = {
3088
3207
  "class": applyChangeToClass,
3089
3208
  "attr": applyChangeToAttribute,
3090
3209
  "style": applyChangeToStyle,
3210
+ "command": applyChangeToCommand,
3091
3211
  };
3092
3212
  const applyChangeByBindingType = {
3093
3213
  "text": applyChangeToText,
@@ -4821,6 +4941,117 @@ function createLoopContextStack() {
4821
4941
  return new LoopContextStack();
4822
4942
  }
4823
4943
 
4944
+ /**
4945
+ * `$commandTokens: ["a", "b", ...]` 配列宣言を解析し、宣言された名前群を Set で返す。
4946
+ *
4947
+ * 注入は行わず、proxy 側で `state.$command.<name>` として token を解決する設計。
4948
+ * (以前の実装は state 直下に各名前の getter を注入していたが、リアクティブ値との
4949
+ * 名前空間衝突を避け識別性を上げるため `$command` ネームスペース集約に切り替え。)
4950
+ *
4951
+ * 対応している宣言形式は **オブジェクトリテラル** のみ。
4952
+ * クラス本体に `static $commandTokens = [...]` を書く形式や、
4953
+ * クラスのプロトタイプ上の同名コマンドの検出は現状サポートしない。
4954
+ */
4955
+ function processCommandTokensDeclaration(state) {
4956
+ const names = new Set();
4957
+ const declared = state[STATE_COMMAND_TOKENS_NAME];
4958
+ if (typeof declared === "undefined") {
4959
+ return names;
4960
+ }
4961
+ if (!Array.isArray(declared)) {
4962
+ raiseError(`${STATE_COMMAND_TOKENS_NAME} must be an array of strings.`);
4963
+ }
4964
+ for (const name of declared) {
4965
+ if (typeof name !== "string" || name.length === 0) {
4966
+ raiseError(`${STATE_COMMAND_TOKENS_NAME} entries must be non-empty strings.`);
4967
+ }
4968
+ if (name === STATE_COMMAND_NAMESPACE_NAME) {
4969
+ raiseError(`${STATE_COMMAND_TOKENS_NAME} entry "${name}" conflicts with the reserved namespace name "${STATE_COMMAND_NAMESPACE_NAME}".`);
4970
+ }
4971
+ if (names.has(name)) {
4972
+ raiseError(`${STATE_COMMAND_TOKENS_NAME} entry "${name}" is duplicated.`);
4973
+ }
4974
+ names.add(name);
4975
+ }
4976
+ return names;
4977
+ }
4978
+
4979
+ const registryByStateElement = new WeakMap();
4980
+ function getOrCreateCommandToken(stateElement, name) {
4981
+ let registry = registryByStateElement.get(stateElement);
4982
+ if (typeof registry === "undefined") {
4983
+ registry = new Map();
4984
+ registryByStateElement.set(stateElement, registry);
4985
+ }
4986
+ let token = registry.get(name);
4987
+ if (typeof token === "undefined") {
4988
+ token = new CommandToken(name);
4989
+ registry.set(name, token);
4990
+ }
4991
+ return token;
4992
+ }
4993
+ function clearCommandTokenRegistry(stateElement) {
4994
+ registryByStateElement.delete(stateElement);
4995
+ }
4996
+
4997
+ /**
4998
+ * `state.$command` でアクセスされる command token の namespace proxy を提供する。
4999
+ *
5000
+ * - state element 単位で memo 化し、同一 stateElement なら同じ proxy が返る。
5001
+ * - 宣言された名前 (`$commandTokens` に列挙されたもの) のみ token を返す。
5002
+ * 宣言外の名前にアクセスした場合は undefined を返す。
5003
+ * (`constructor` / `Symbol.toPrimitive` / `then` など内部システムが触るキーで
5004
+ * 例外を投げないため。typo は subsequent な `.emit()` 呼び出しで TypeError として
5005
+ * 間接的に表面化する。)
5006
+ * - token そのものの memo は `getOrCreateCommandToken` 側に集約されており、
5007
+ * namespace proxy は薄いゲートウェイとして振る舞う。
5008
+ */
5009
+ const namespaceProxyByStateElement = new WeakMap();
5010
+ function getCommandNamespace(stateElement) {
5011
+ const cached = namespaceProxyByStateElement.get(stateElement);
5012
+ if (typeof cached !== "undefined") {
5013
+ return cached;
5014
+ }
5015
+ const proxy = new Proxy(Object.create(null), {
5016
+ get(_target, prop) {
5017
+ if (typeof prop !== "string") {
5018
+ return undefined;
5019
+ }
5020
+ if (!stateElement.commandTokenNames.has(prop)) {
5021
+ return undefined;
5022
+ }
5023
+ return getOrCreateCommandToken(stateElement, prop);
5024
+ },
5025
+ has(_target, prop) {
5026
+ return typeof prop === "string" && stateElement.commandTokenNames.has(prop);
5027
+ },
5028
+ ownKeys() {
5029
+ return Array.from(stateElement.commandTokenNames);
5030
+ },
5031
+ getOwnPropertyDescriptor(_target, prop) {
5032
+ if (typeof prop === "string" && stateElement.commandTokenNames.has(prop)) {
5033
+ return {
5034
+ configurable: true,
5035
+ enumerable: true,
5036
+ value: getOrCreateCommandToken(stateElement, prop),
5037
+ };
5038
+ }
5039
+ return undefined;
5040
+ },
5041
+ set() {
5042
+ raiseError(`$command namespace is read-only; assigning to it is not allowed.`);
5043
+ },
5044
+ deleteProperty() {
5045
+ raiseError(`$command namespace is read-only; deleting from it is not allowed.`);
5046
+ },
5047
+ });
5048
+ namespaceProxyByStateElement.set(stateElement, proxy);
5049
+ return proxy;
5050
+ }
5051
+ function clearCommandNamespace(stateElement) {
5052
+ namespaceProxyByStateElement.delete(stateElement);
5053
+ }
5054
+
4824
5055
  function getterFn(name) {
4825
5056
  return function () {
4826
5057
  const stateEl = this.stateElement;
@@ -4832,7 +5063,8 @@ function getterFn(name) {
4832
5063
  value = state[name];
4833
5064
  });
4834
5065
  }
4835
- catch {
5066
+ catch (e) {
5067
+ console.warn(`[@wcstack/state] DCC getter "${name}" failed:`, e);
4836
5068
  return undefined;
4837
5069
  }
4838
5070
  return value;
@@ -4855,22 +5087,25 @@ function callFn(name, isAsync) {
4855
5087
  return function (...args) {
4856
5088
  const stateEl = this.stateElement;
4857
5089
  if (!stateEl)
4858
- return;
5090
+ return undefined;
4859
5091
  return stateEl.initializePromise.then(() => {
5092
+ let result;
4860
5093
  return stateEl.createStateAsync("writable", async (state) => {
4861
- await state[name](...args);
4862
- });
5094
+ result = await state[name](...args);
5095
+ }).then(() => result);
4863
5096
  });
4864
5097
  };
4865
5098
  }
4866
5099
  return function (...args) {
4867
5100
  const stateEl = this.stateElement;
4868
5101
  if (!stateEl)
4869
- return;
4870
- stateEl.initializePromise.then(() => {
5102
+ return undefined;
5103
+ return stateEl.initializePromise.then(() => {
5104
+ let result;
4871
5105
  stateEl.createState("writable", (state) => {
4872
- state[name](...args);
5106
+ result = state[name](...args);
4873
5107
  });
5108
+ return result;
4874
5109
  });
4875
5110
  };
4876
5111
  }
@@ -4904,7 +5139,8 @@ function defineDCC(hostElement, shadowRoot, state) {
4904
5139
  raiseError(`DCC: "${tagName}" is not a valid custom element name (must contain a hyphen).`);
4905
5140
  }
4906
5141
  if (customElements.get(tagName)) {
4907
- // 既に登録済みならスキップ
5142
+ // 既に登録済みならスキップ(重複定義の検知のため警告は出す)
5143
+ console.warn(`[@wcstack/state] DCC: "${tagName}" is already registered. Skipping redefinition.`);
4908
5144
  return;
4909
5145
  }
4910
5146
  // ShadowRoot は cloneNode 不可のため、template 経由で内容をクローン
@@ -6025,6 +6261,9 @@ function get(target, prop, receiver, handler) {
6025
6261
  return trackDependency(target, prop, receiver, handler)(path);
6026
6262
  };
6027
6263
  }
6264
+ case STATE_COMMAND_NAMESPACE_NAME: {
6265
+ return getCommandNamespace(handler.stateElement);
6266
+ }
6028
6267
  }
6029
6268
  }
6030
6269
  else {
@@ -6630,6 +6869,7 @@ class State extends HTMLElement {
6630
6869
  _boundComponent = null;
6631
6870
  _boundComponentStateProp = null;
6632
6871
  _bindableEventMap = {};
6872
+ _commandTokenNames = new Set();
6633
6873
  constructor() {
6634
6874
  super();
6635
6875
  this._initializePromise = new Promise((resolve) => {
@@ -6652,6 +6892,7 @@ class State extends HTMLElement {
6652
6892
  return this.__state;
6653
6893
  }
6654
6894
  set _state(value) {
6895
+ this._commandTokenNames = processCommandTokensDeclaration(value);
6655
6896
  this.__state = value;
6656
6897
  this._listPaths.clear();
6657
6898
  this._elementPaths.clear();
@@ -6864,6 +7105,8 @@ class State extends HTMLElement {
6864
7105
  if (this._rootNode !== null) {
6865
7106
  this._callStateDisconnectedCallback();
6866
7107
  setStateElementByName(this.rootNode, this._name, null);
7108
+ clearCommandTokenRegistry(this);
7109
+ clearCommandNamespace(this);
6867
7110
  this._rootNode = null;
6868
7111
  }
6869
7112
  }
@@ -6909,6 +7152,9 @@ class State extends HTMLElement {
6909
7152
  get bindableEventMap() {
6910
7153
  return this._bindableEventMap;
6911
7154
  }
7155
+ get commandTokenNames() {
7156
+ return this._commandTokenNames;
7157
+ }
6912
7158
  setBindableEventMap(map) {
6913
7159
  this._bindableEventMap = map;
6914
7160
  }