@wcstack/state 2.1.1 → 2.2.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
@@ -220,6 +220,7 @@ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
220
220
  const STATE_CONNECTED_CALLBACK_NAME = "$connectedCallback";
221
221
  const STATE_DISCONNECTED_CALLBACK_NAME = "$disconnectedCallback";
222
222
  const STATE_UPDATED_CALLBACK_NAME = "$updatedCallback";
223
+ const STATE_ERROR_CALLBACK_NAME = "$errorCallback";
223
224
  const WEBCOMPONENT_STATE_READY_CALLBACK_NAME = "$stateReadyCallback";
224
225
  const STATE_BINDABLES_NAME = "$bindables";
225
226
  const STATE_COMMANDS_NAME = "$commands";
@@ -2508,6 +2509,7 @@ function buildMountRecord(component, stateProp, bindings, parentStateElement, st
2508
2509
  accessorBySuffixByMarkerParent: new Map(),
2509
2510
  indexShiftByLoopElementPath: new Map(),
2510
2511
  addedGetterPaths: new Set(),
2512
+ exports: new Map(),
2511
2513
  };
2512
2514
  }
2513
2515
  function firstSegmentOf(path) {
@@ -2973,6 +2975,7 @@ const setByAddressSymbol = Symbol("$$setByAddress");
2973
2975
  const connectedCallbackSymbol = Symbol("$$connectedCallback");
2974
2976
  const disconnectedCallbackSymbol = Symbol("$$disconnectedCallback");
2975
2977
  const updatedCallbackSymbol = Symbol("$$updatedCallback");
2978
+ const errorCallbackSymbol = Symbol("$$errorCallback");
2976
2979
 
2977
2980
  const _cache$3 = new WeakMap();
2978
2981
  function getAbsolutePathInfo(stateElement, pathInfo) {
@@ -4225,6 +4228,46 @@ const handlerByHandlerKey = new Map();
4225
4228
  const bindingRegistry = createHandlerBindingRegistry();
4226
4229
  const producerValueObserversByNode = new WeakMap();
4227
4230
  const DEFAULT_GETTER = (e) => e.detail;
4231
+ /**
4232
+ * 既定 getter(`(e) => e.detail`)が要素の宣言と噛み合っていない典型 2 形を、
4233
+ * 要素 × プロパティごとに 1 回だけ警告する(README「What the element writes back」)。
4234
+ *
4235
+ * (a) detail が undefined なのに `element[propName]` には値がある —
4236
+ * CustomEvent でない Event を dispatch している / `detail` を付け忘れている
4237
+ * (b) detail が `{ <propName>: … }` の形のラッパーで、`element[propName]` はオブジェクトでない —
4238
+ * `getter: (e) => e.detail.<propName>` が要る
4239
+ *
4240
+ * どちらも state には黙って undefined / ラッパーが書かれ、例外も lint 診断も出ない
4241
+ * (payload の形は静的に見えない)。挙動は変えない — 書き込みはそのまま行う。
4242
+ * occurrence(`semantics: "event"`)は payload が任意なので対象外(呼び出し側で除外)。
4243
+ */
4244
+ const warnedDefaultGetter = new WeakMap();
4245
+ function warnDefaultGetterMismatch(node, propName, detail) {
4246
+ const propValue = node[propName];
4247
+ let reason = null;
4248
+ if (typeof detail === "undefined") {
4249
+ if (typeof propValue !== "undefined") {
4250
+ reason = `the event carried no detail (undefined) while element.${propName} is ${typeof propValue}`;
4251
+ }
4252
+ }
4253
+ else if (detail !== null && typeof detail === "object" && Object.prototype.hasOwnProperty.call(detail, propName)
4254
+ && (propValue === null || typeof propValue !== "object")) {
4255
+ reason = `the event's detail is an object with a "${propName}" key while element.${propName} is ${typeof propValue}`;
4256
+ }
4257
+ if (reason === null)
4258
+ return;
4259
+ let props = warnedDefaultGetter.get(node);
4260
+ if (typeof props === "undefined") {
4261
+ props = new Set();
4262
+ warnedDefaultGetter.set(node, props);
4263
+ }
4264
+ if (props.has(propName))
4265
+ return;
4266
+ props.add(propName);
4267
+ console.warn(`[@wcstack/state] [wcs/default-getter-mismatch] <${node.tagName.toLowerCase()}> "${propName}": ${reason}. ` +
4268
+ `With no getter, state receives e.detail as-is. Dispatch the value itself as detail, or declare ` +
4269
+ `getter (e.g. (e) => e.detail.${propName}, or (e) => e.target.${propName}) on that wcBindable property.`);
4270
+ }
4228
4271
  function getHandlerKey(binding, eventName, hasGetter, isOccurrence) {
4229
4272
  const filterKey = binding.inFilters.map(f => f.filterName + '(' + f.args.join(',') + ')').join('|');
4230
4273
  return `${binding.propName}::${binding.statePathName}::${eventName}::${filterKey}::${hasGetter ? 'g' : 'n'}::${isOccurrence ? 'o' : 's'}`;
@@ -4285,6 +4328,9 @@ const twowayEventHandlerFunction = (propName, statePathName, inFilters, valueGet
4285
4328
  let newValue;
4286
4329
  if (valueGetter !== null) {
4287
4330
  newValue = valueGetter(event);
4331
+ if (valueGetter === DEFAULT_GETTER && !isOccurrence) {
4332
+ warnDefaultGetterMismatch(node, propName, newValue);
4333
+ }
4288
4334
  }
4289
4335
  else {
4290
4336
  if (!(propName in node)) {
@@ -7581,6 +7627,113 @@ function clearSsrPropertyStore() {
7581
7627
  trackedNodes.clear();
7582
7628
  }
7583
7629
 
7630
+ /**
7631
+ * Trusted Types (`require-trusted-types-for 'script'`) 対応。正本は docs/csp.md §7。
7632
+ *
7633
+ * state が HTML sink に流すのは **状態の値**(`innerHTML: path` などのプロパティ
7634
+ * バインド)で、ユーザー入力が混ざり得る文字列そのもの。ここに identity policy を
7635
+ * 噛ませて通すのは TT の無効化と同義なので、state は自前の policy を作らない。
7636
+ * 利用側が sanitizer を持つ policy を注入したときだけ通し、無ければ従来どおり
7637
+ * ブラウザに弾かせる(ただし何を設定すれば直るかは必ず言う)。
7638
+ *
7639
+ * 注入口は全 @wcstack パッケージ共通のグローバルスロット。buildless(CDN 一発)でも
7640
+ * inline script 1 本で差し込める:
7641
+ *
7642
+ * ```js
7643
+ * globalThis[Symbol.for("wcstack.trustedTypes.policy")] =
7644
+ * trustedTypes.createPolicy("my-app", { createHTML: (s) => DOMPurify.sanitize(s) });
7645
+ * ```
7646
+ *
7647
+ * バンドラ経由なら `setTrustedTypesPolicy()` を使う。値は毎回スロットから読むので
7648
+ * 後から差し替えても効く(identity policy を作る router / worker 側だけは
7649
+ * `createPolicy` の重複を避けるため生成結果をシングルトンで保持する)。
7650
+ */
7651
+ /** 利用側が policy を差し込むグローバルスロット(全 @wcstack パッケージ共通)。 */
7652
+ const TRUSTED_TYPES_POLICY_SLOT = Symbol.for("wcstack.trustedTypes.policy");
7653
+ /**
7654
+ * 利用側が注入した policy を返す。state はここに identity policy をフォールバック
7655
+ * させない(それをやると TT を無効化することになる)。
7656
+ */
7657
+ function getTrustedTypesPolicy() {
7658
+ const value = globalThis[TRUSTED_TYPES_POLICY_SLOT];
7659
+ if (value === null || typeof value !== "object")
7660
+ return null;
7661
+ return value;
7662
+ }
7663
+ /** 利用側 policy を設定する(`null` で解除)。最初のバインド適用前に呼ぶこと。 */
7664
+ function setTrustedTypesPolicy(policy) {
7665
+ globalThis[TRUSTED_TYPES_POLICY_SLOT] = policy;
7666
+ }
7667
+ /**
7668
+ * TrustedHTML が要求されるプロパティか。`textContent` などの安全な sink は含めない。
7669
+ * ホットパス(全プロパティ書き込み)から呼ばれるので文字列比較だけで済ませる。
7670
+ */
7671
+ function isHtmlSinkProp(prop) {
7672
+ return prop === "innerHTML" || prop === "outerHTML" || prop === "srcdoc";
7673
+ }
7674
+ /**
7675
+ * HTML sink へ書く値を利用側 policy に通す。policy が無ければ値をそのまま返す
7676
+ * = TT 有効下ではブラウザが弾く(意図どおり)。policy がある場合は TT 非対応
7677
+ * ブラウザでも通す: sanitizer は Chromium だけで効いても意味がないため。
7678
+ */
7679
+ function trustHtmlValue(value) {
7680
+ if (typeof value !== "string")
7681
+ return value;
7682
+ const policy = getTrustedTypesPolicy();
7683
+ const createHTML = policy?.createHTML;
7684
+ if (typeof createHTML !== "function")
7685
+ return value;
7686
+ return createHTML.call(policy, value);
7687
+ }
7688
+ let _enforced = undefined;
7689
+ /**
7690
+ * TT が実際に強制されているかを実測する。エラーメッセージの文言に依存しないよう、
7691
+ * 使い捨ての要素へ実際に書いて確かめる。`default` policy がある場合は書き込みが
7692
+ * 通る=我々の書き込みも通るので、正しく false になる。
7693
+ *
7694
+ * cold path(書き込みが失敗した後)でしか呼ばれない。
7695
+ */
7696
+ function isTrustedTypesEnforced() {
7697
+ if (_enforced !== undefined)
7698
+ return _enforced;
7699
+ if (!("trustedTypes" in globalThis)) {
7700
+ _enforced = false;
7701
+ return _enforced;
7702
+ }
7703
+ try {
7704
+ document.createElement("div").innerHTML = "<i></i>";
7705
+ _enforced = false;
7706
+ }
7707
+ catch {
7708
+ _enforced = true;
7709
+ }
7710
+ return _enforced;
7711
+ }
7712
+ let _reported = false;
7713
+ /**
7714
+ * HTML sink への書き込み失敗を診断する。applyChangeToProperty の catch は
7715
+ * `config.debug` 時しか warn しないため、TT が原因のときは黙って壊れていた。
7716
+ * 原因と直し方が分かる形で一度だけ報告する。
7717
+ */
7718
+ function reportTrustedTypesBlock(element, prop) {
7719
+ if (_reported)
7720
+ return;
7721
+ if (!isTrustedTypesEnforced())
7722
+ return;
7723
+ _reported = true;
7724
+ const hasPolicy = typeof getTrustedTypesPolicy()?.createHTML === "function";
7725
+ const cause = hasPolicy
7726
+ ? "The injected policy's createHTML() did not return a TrustedHTML."
7727
+ : "No sanitizing policy is installed, and @wcstack/state deliberately does not "
7728
+ + "pass state values through an identity policy — that would defeat the CSP.";
7729
+ console.error(`[@wcstack/state] Writing to "${prop}" was blocked by Trusted Types `
7730
+ + `(require-trusted-types-for 'script'). ${cause}\n`
7731
+ + `Install a sanitizing policy before the first binding is applied:\n`
7732
+ + ` globalThis[Symbol.for("wcstack.trustedTypes.policy")] =\n`
7733
+ + ` trustedTypes.createPolicy("my-app", { createHTML: (s) => DOMPurify.sanitize(s) });\n`
7734
+ + `Or bind the value as text instead of HTML. See docs/csp.md section 7.`, { element, property: prop });
7735
+ }
7736
+
7584
7737
  // SSR 時に HTML 属性で代替可能なプロパティ
7585
7738
  // これら以外のプロパティは ssrPropertyStore に蓄積してハイドレーション時に復元
7586
7739
  const SSR_ATTR_PROPS = {
@@ -7652,13 +7805,23 @@ function applyChangeToProperty(binding, _context, newValue) {
7652
7805
  && getCustomElement(element) !== null) {
7653
7806
  rememberOverwrittenObject(element, firstSegment, current);
7654
7807
  }
7808
+ // Trusted Types: HTML sink (`innerHTML` 等) への書き込みだけ、利用側が注入した
7809
+ // sanitizer 付き policy を通す。state が identity policy を作って素通しさせるのは
7810
+ // TT の無効化と同義なので採らない(docs/csp.md §7)。sink 以外は文字列比較 3 回で
7811
+ // 抜けるので、ホットパスの実コストはほぼ無い。
7812
+ const isHtmlSink = isHtmlSinkProp(firstSegment);
7655
7813
  const performWrite = () => {
7656
7814
  let propertyWriteSucceeded = false;
7657
7815
  try {
7658
- element[firstSegment] = newValue;
7816
+ element[firstSegment] = isHtmlSink ? trustHtmlValue(newValue) : newValue;
7659
7817
  propertyWriteSucceeded = true;
7660
7818
  }
7661
7819
  catch (error) {
7820
+ // TT が原因のときは config.debug に関係なく報告する。ここを黙って握り潰すと
7821
+ // 「バインドを書いたのに何も起きない」という最悪の壊れ方をする。
7822
+ if (isHtmlSink) {
7823
+ reportTrustedTypesBlock(element, firstSegment);
7824
+ }
7662
7825
  if (config.debug) {
7663
7826
  console.warn(`Failed to set property '${firstSegment}' on element.`, {
7664
7827
  element,
@@ -8275,6 +8438,61 @@ function checkDeclaredPath(stateElement, state, path, source) {
8275
8438
  if (result.existence !== "missing") {
8276
8439
  return;
8277
8440
  }
8441
+ if (isExportedPath(stateElement, path)) {
8442
+ return;
8443
+ }
8444
+ if (source === "binding") {
8445
+ // 遅延報告(docs/state-overlay-export-design.md X7): バインド確立時点では、その位置に
8446
+ // マウントされるコンポーネントの getter(公開 getter)がまだ登録されていない。
8447
+ // 1 マクロタスク待って、登録で解消しなかったものだけを報告する
8448
+ deferReport(stateElement, path, result);
8449
+ return;
8450
+ }
8451
+ reportMissing(stateElement, path, source, result);
8452
+ }
8453
+ const deferredReportsByStateElement = new WeakMap();
8454
+ const flushScheduled = new WeakSet();
8455
+ const exportedPathsByStateElement = new WeakMap();
8456
+ /** 公開 getter の登録(webComponent/exportIndex.ts)— このパスは「存在しない」ではない */
8457
+ function markExportedPath(stateElement, path) {
8458
+ let paths = exportedPathsByStateElement.get(stateElement);
8459
+ if (typeof paths === "undefined") {
8460
+ paths = new Set();
8461
+ exportedPathsByStateElement.set(stateElement, paths);
8462
+ }
8463
+ paths.add(path);
8464
+ deferredReportsByStateElement.get(stateElement)?.delete(path);
8465
+ }
8466
+ function isExportedPath(stateElement, path) {
8467
+ return exportedPathsByStateElement.get(stateElement)?.has(path) === true;
8468
+ }
8469
+ function deferReport(stateElement, path, result) {
8470
+ let pending = deferredReportsByStateElement.get(stateElement);
8471
+ if (typeof pending === "undefined") {
8472
+ pending = new Map();
8473
+ deferredReportsByStateElement.set(stateElement, pending);
8474
+ }
8475
+ pending.set(path, result);
8476
+ if (flushScheduled.has(stateElement)) {
8477
+ return;
8478
+ }
8479
+ flushScheduled.add(stateElement);
8480
+ setTimeout(() => flushDeferredPathReports(stateElement), 0);
8481
+ }
8482
+ /** 遅延中の報告を今すぐ流す(タイマー到達時・テスト用) */
8483
+ function flushDeferredPathReports(stateElement) {
8484
+ flushScheduled.delete(stateElement);
8485
+ const pending = deferredReportsByStateElement.get(stateElement);
8486
+ if (typeof pending === "undefined") {
8487
+ return;
8488
+ }
8489
+ deferredReportsByStateElement.delete(stateElement);
8490
+ // 登録で解消したものは markExportedPath が pending から消している
8491
+ for (const [path, result] of pending) {
8492
+ reportMissing(stateElement, path, "binding", result);
8493
+ }
8494
+ }
8495
+ function reportMissing(stateElement, path, source, result) {
8278
8496
  // 接頭辞は raiseError と同じ `[@wcstack/state] [wcs/...]` の並び(コンソールの
8279
8497
  // grep 単位をパッケージで揃える)
8280
8498
  console.warn(`[@wcstack/state] [${DIAGNOSTIC_CODE[source]}] ${SUBJECT[source]} "${path}" does not resolve on the state tree: ` +
@@ -8435,7 +8653,7 @@ function _applyChange(binding, context) {
8435
8653
  const value = getValue(context.state, binding);
8436
8654
  const filteredValue = getFilteredValue(value, binding.outFilters);
8437
8655
  if (deferredSelectBindingByBinding.get(binding) === true) {
8438
- context.deferredSelectBindings.push({ binding, value: filteredValue });
8656
+ context.deferredSelectBindings.push({ binding, value: filteredValue, stateElement: context.stateElement });
8439
8657
  return;
8440
8658
  }
8441
8659
  let fn = fnByBinding.get(binding);
@@ -8475,7 +8693,7 @@ function _applyChange(binding, context) {
8475
8693
  if (element.tagName === 'SELECT') {
8476
8694
  const propName = binding.propSegments[0];
8477
8695
  if (propName === 'value' || propName === 'selectedIndex') {
8478
- context.deferredSelectBindings.push({ binding, value: filteredValue });
8696
+ context.deferredSelectBindings.push({ binding, value: filteredValue, stateElement: context.stateElement });
8479
8697
  deferredSelectBindingByBinding.set(binding, true);
8480
8698
  return;
8481
8699
  }
@@ -8569,10 +8787,31 @@ function applyChange(binding, context) {
8569
8787
  * `console.error` だけだと devtools からは「静かに握られた失敗」が見えないため、
8570
8788
  * 同じ地点から sink にも流す(`state:watch-error` と同じ位置づけ)。
8571
8789
  * 値と DOM は巻き戻さない — 伝播 hop 上限超過・watch 連鎖打ち切りと同じ姿勢。
8790
+ *
8791
+ * state が `$errorCallback` を宣言していれば、console.error の代わりにそこへ配送する
8792
+ * (作者が報告を引き取った。ページ内で受けるための口)。配送は batch の末尾 —
8793
+ * $updatedCallback と同じ位置 — にまとめる。devtools sink へは宣言の有無に関わらず流す。
8572
8794
  */
8573
- function reportBindingApplyError(binding, error) {
8574
- console.error(`[@wcstack/state] binding "${binding.bindingType}: ${binding.statePathName}" failed to apply; ` +
8575
- `the rest of this batch continues.`, { node: binding.node, error });
8795
+ function reportBindingApplyError(binding, error, stateElement, failuresByStateElement) {
8796
+ const handled = stateElement !== null && stateElement.hasErrorCallback === true;
8797
+ if (handled) {
8798
+ const info = {
8799
+ path: binding.statePathName,
8800
+ bindingType: binding.bindingType,
8801
+ node: binding.node,
8802
+ };
8803
+ const failures = failuresByStateElement.get(stateElement);
8804
+ if (failures === undefined) {
8805
+ failuresByStateElement.set(stateElement, [{ error, info }]);
8806
+ }
8807
+ else {
8808
+ failures.push({ error, info });
8809
+ }
8810
+ }
8811
+ else {
8812
+ console.error(`[@wcstack/state] binding "${binding.bindingType}: ${binding.statePathName}" failed to apply; ` +
8813
+ `the rest of this batch continues.`, { node: binding.node, error });
8814
+ }
8576
8815
  if (devtoolsSink !== null) {
8577
8816
  devtoolsSink({
8578
8817
  type: "state:binding-apply-error",
@@ -8598,6 +8837,7 @@ function applyChangeFromBindings(bindings, propagationContextByBinding) {
8598
8837
  const newListValueByAbsAddress = new Map();
8599
8838
  const updatedAbsAddressSetByStateElement = new Map();
8600
8839
  const deferredSelectBindings = [];
8840
+ const failuresByStateElement = new Map();
8601
8841
  // Phase 1: 構造的更新 + 値更新(select.value/selectedIndex は遅延)
8602
8842
  while (bindingIndex < bindings.length) {
8603
8843
  let binding = bindings[bindingIndex];
@@ -8643,7 +8883,7 @@ function applyChangeFromBindings(bindings, propagationContextByBinding) {
8643
8883
  applyChange(binding, context);
8644
8884
  }
8645
8885
  catch (error) {
8646
- reportBindingApplyError(binding, error);
8886
+ reportBindingApplyError(binding, error, stateElement, failuresByStateElement);
8647
8887
  }
8648
8888
  bindingIndex++;
8649
8889
  const nextBindingInfo = bindings[bindingIndex];
@@ -8659,12 +8899,12 @@ function applyChangeFromBindings(bindings, propagationContextByBinding) {
8659
8899
  // Phase 2: 遅延されたselect.value/selectedIndex を適用
8660
8900
  // applyChangeToProperty は propagationContextByBinding 以外の context を
8661
8901
  // 参照しないため、遅延分は最小 context を渡す
8662
- for (const { binding, value } of deferredSelectBindings) {
8902
+ for (const { binding, value, stateElement } of deferredSelectBindings) {
8663
8903
  try {
8664
8904
  applyChangeToProperty(binding, { propagationContextByBinding }, value);
8665
8905
  }
8666
8906
  catch (error) {
8667
- reportBindingApplyError(binding, error);
8907
+ reportBindingApplyError(binding, error, stateElement ?? null, failuresByStateElement);
8668
8908
  }
8669
8909
  }
8670
8910
  for (const [absAddress, newListValue] of newListValueByAbsAddress.entries()) {
@@ -8675,6 +8915,20 @@ function applyChangeFromBindings(bindings, propagationContextByBinding) {
8675
8915
  state[updatedCallbackSymbol](Array.from(absAddressSet));
8676
8916
  });
8677
8917
  }
8918
+ // $errorCallback の配送。$updatedCallback の後・失敗した本数ぶん・this は writable proxy。
8919
+ // callback 自身の throw は隔離する — 1 件の報告失敗が残りの報告と drain を道連れにしない
8920
+ for (const [stateElement, failures] of failuresByStateElement.entries()) {
8921
+ stateElement.createState("writable", (state) => {
8922
+ for (const { error, info } of failures) {
8923
+ try {
8924
+ state[errorCallbackSymbol](error, info);
8925
+ }
8926
+ catch (callbackError) {
8927
+ console.error(`[@wcstack/state] $errorCallback threw while handling the failure of binding "${info.bindingType}: ${info.path}".`, { error: callbackError, original: error, node: info.node });
8928
+ }
8929
+ }
8930
+ });
8931
+ }
8678
8932
  }
8679
8933
 
8680
8934
  function scheduleDeferredSpreads(deferredSpreads, parentLoopContext, session) {
@@ -9182,7 +9436,7 @@ async function buildBindings(root) {
9182
9436
  }
9183
9437
  }
9184
9438
 
9185
- var version = "2.1.1";
9439
+ var version = "2.2.0";
9186
9440
  var pkg = {
9187
9441
  version: version};
9188
9442
 
@@ -10856,6 +11110,7 @@ function registerDevtoolsSource() {
10856
11110
  delta: record.delta,
10857
11111
  privateKeys: Object.keys(record.privateSnapshot),
10858
11112
  getterKeys: [...record.getterKeys],
11113
+ exports: [...record.exports.keys()],
10859
11114
  }));
10860
11115
  },
10861
11116
  keys(rootNode) {
@@ -13073,9 +13328,32 @@ function defineDCC(hostElement, shadowRoot, state) {
13073
13328
  // 一意性はレジストリ単位なので、別スコープの同名 DCC は衝突しない。
13074
13329
  raiseError(`DCC: "${tagName}" is already registered. A custom element name can only be defined once.`);
13075
13330
  }
13076
- // ShadowRoot cloneNode 不可のため、template 経由で内容をクローン
13331
+ // ShadowRoot 自体は cloneNode 不可なので、子ノードを 1 つずつ template へ取り込む。
13332
+ //
13333
+ // かつては `template.innerHTML = shadowRoot.innerHTML` と serialize → parse で
13334
+ // 往復していた。これをやめたのは 3 点の理由による(docs/csp.md §7):
13335
+ // (1) `require-trusted-types-for 'script'` 下では innerHTML sink が弾かれる。
13336
+ // ここはテンプレート=作者が書いた DOM の複製でしかないので、policy を作って
13337
+ // 署名するより sink 自体を無くすほうが筋が良い(state が CSP の
13338
+ // `trusted-types` allowlist を要求しなくなる)。
13339
+ // (2) 往復のたびに HTML パーサの再解釈が挟まり、元の DOM と一致しない結果に
13340
+ // なり得る(mXSS と同じ機序)。
13341
+ // (3) 単純に serialize + parse のぶん遅い。
13342
+ //
13343
+ // importNode は **取り込み先 document** で要素を作るため、template の inert な
13344
+ // contents document 側から呼べば従来どおり「未 upgrade の複製」になる。live
13345
+ // document 側で cloneNode すると upgrade reaction が走り、_ensureShadow の明示
13346
+ // upgrade と二重になる。
13347
+ //
13348
+ // 挙動差が 1 つある: script 要素の already-started フラグは複製時に引き継がれる
13349
+ // ため、テンプレート内のインライン `<script>` はインスタンス生成のたびにネイティブ
13350
+ // 実行されなくなる。`<wcs-state>` の状態定義スクリプトは text を読んで評価する実装
13351
+ // (loadFromInnerScript)なので影響を受けない。
13077
13352
  const template = document.createElement("template");
13078
- template.innerHTML = shadowRoot.innerHTML;
13353
+ const inertDocument = template.content.ownerDocument;
13354
+ for (const childNode of Array.from(shadowRoot.childNodes)) {
13355
+ template.content.appendChild(inertDocument.importNode(childNode, true));
13356
+ }
13079
13357
  const shadowRootMode = shadowRoot.mode;
13080
13358
  // $bindables / $commands から wcBindable + bindableEventMap を生成
13081
13359
  const { bindables, commands, streamBackedBindables } = processDccDeclarations(state);
@@ -13620,6 +13898,22 @@ function createOverlayValue(record, address, receiver, handler) {
13620
13898
  const privateData = isBase ? getPrivateData(record, address.listIndex) : {};
13621
13899
  return new Proxy(privateData, new OverlayValueHandler(record, markerParentPath, address.listIndex, isBase, receiver, handler));
13622
13900
  }
13901
+ /**
13902
+ * 公開 getter の読み(docs/state-overlay-export-design.md §2-1 の 4)。
13903
+ * `P.#m<id>` のオーバーレイ値に対する `Reflect.get(proxy, k)` と等価 — 作者の getter は
13904
+ * マーカーアドレスを push して評価されるので、依存辺・キャッシュはマーカー側に載る。
13905
+ */
13906
+ function readExportedAccessor(record, entry, listIndex, receiver, handler) {
13907
+ const address = createStateAddress(getPathInfo(entry.markerTerminalPath), listIndex);
13908
+ const proxy = createOverlayValue(record, address, receiver, handler);
13909
+ return Reflect.get(proxy, entry.suffix);
13910
+ }
13911
+ /** 公開 getter への書き込み(X9): setter があれば評価、無ければ overlay の set が raise する。 */
13912
+ function writeExportedAccessor(record, entry, listIndex, value, receiver, handler) {
13913
+ const address = createStateAddress(getPathInfo(entry.markerTerminalPath), listIndex);
13914
+ const proxy = createOverlayValue(record, address, receiver, handler);
13915
+ return Reflect.set(proxy, entry.suffix, value);
13916
+ }
13623
13917
  /**
13624
13918
  * `element.state` の公開面(chroot・M13)。相対キーを変換して親の proxy を通すだけの
13625
13919
  * 薄い翻訳で、値の解決(私有・getter・ツリー)は全て親ウォーク+オーバーレイが担う。
@@ -13705,6 +13999,203 @@ function createPublicMountState(record) {
13705
13999
  });
13706
14000
  }
13707
14001
 
14002
+ const exportIndexByStateElement = new WeakMap();
14003
+ const reportedShadows = new Set();
14004
+ function slotFor(stateElement, parentPath, key, create) {
14005
+ let byParent = exportIndexByStateElement.get(stateElement);
14006
+ if (typeof byParent === "undefined") {
14007
+ if (!create)
14008
+ return null;
14009
+ byParent = new Map();
14010
+ exportIndexByStateElement.set(stateElement, byParent);
14011
+ }
14012
+ let byKey = byParent.get(parentPath);
14013
+ if (typeof byKey === "undefined") {
14014
+ if (!create)
14015
+ return null;
14016
+ byKey = new Map();
14017
+ byParent.set(parentPath, byKey);
14018
+ }
14019
+ let slot = byKey.get(key);
14020
+ if (typeof slot === "undefined") {
14021
+ if (!create)
14022
+ return null;
14023
+ slot = { holders: new Set(), byListIndex: new WeakMap(), noIndex: null };
14024
+ byKey.set(key, slot);
14025
+ }
14026
+ return slot;
14027
+ }
14028
+ /**
14029
+ * 記録の getter / setter を公開索引に載せる(初回登録で 1 回・冪等)。
14030
+ * translateInnerPath のマーカー化を通すので accessorBySuffixByMarkerParent も同時に埋まる。
14031
+ * 翻訳できないアクセサ(ワイルドカード終端・部分マウントのみで接頭辞不一致)と、
14032
+ * `$` 名前空間のアクセサ(翻訳されずマーカーが付かない)は公開しない。
14033
+ * ルートエントリの無い部分マウントは公開位置(ツリー上のパス)を持たないので対象外。
14034
+ */
14035
+ function registerExports(record) {
14036
+ if (record.exports.size > 0 || record.rootEntry === null) {
14037
+ return;
14038
+ }
14039
+ const keys = new Set([...record.getterKeys, ...record.setterKeys]);
14040
+ for (const key of keys) {
14041
+ let markerPath;
14042
+ try {
14043
+ markerPath = translateInnerPath(record, key);
14044
+ }
14045
+ catch {
14046
+ continue;
14047
+ }
14048
+ const markerIndex = markerPath.indexOf(DELIMITER + record.marker);
14049
+ if (markerIndex === -1) {
14050
+ continue;
14051
+ }
14052
+ // `users.*.#m7.display` → 末端マーカーパス `users.*.#m7`・接尾 `display`・公開 `users.*.display`
14053
+ // (接尾は常に非空 — markerizeAccessorPath が空を raise 済み。公開パスはルート
14054
+ // エントリの外側パス+接尾なので常に 2 セグメント以上 = 親パスを持つ)
14055
+ const markerTerminalPath = markerPath.slice(0, markerIndex + 1 + record.marker.length);
14056
+ const suffix = markerPath.slice(markerTerminalPath.length + 1);
14057
+ const exportedPath = markerPath.slice(0, markerIndex) + DELIMITER + suffix;
14058
+ const exportedInfo = getPathInfo(exportedPath);
14059
+ // Internal wildcard accessors need their own row resolution and lifecycle
14060
+ // notifications. Only publish accessors at the mount instance's depth.
14061
+ if (exportedInfo.wildcardCount !== record.delta) {
14062
+ continue;
14063
+ }
14064
+ const entry = { markerTerminalPath, suffix, markerPath, exportedPath };
14065
+ record.exports.set(exportedPath, entry);
14066
+ slotFor(record.parentStateElement, exportedInfo.parentPath, exportedInfo.lastSegment, true)
14067
+ .holders.add({ ref: new WeakRef(record), entry });
14068
+ // エイリアス辺(X5): 子 getter のアドレス → 公開パス
14069
+ record.parentStateElement.addDynamicDependency(markerPath, exportedPath);
14070
+ // 未存在パスの遅延診断(X7): この公開パスへのバインドは「存在しない」ではない
14071
+ markExportedPath(record.parentStateElement, exportedPath);
14072
+ }
14073
+ record.parentStateElement.markHasMounts?.();
14074
+ }
14075
+ /** 読みの listIndex がホスト要素のループ文脈と一致するか(配下の深い文脈も一致とみなす) */
14076
+ function isInstanceOf(record, listIndex) {
14077
+ if (!record.component.isConnected) {
14078
+ return false;
14079
+ }
14080
+ const own = getLoopContextByNode(record.component)?.listIndex ?? null;
14081
+ if (listIndex === null) {
14082
+ return own === null;
14083
+ }
14084
+ let current = own;
14085
+ while (current !== null) {
14086
+ if (current === listIndex) {
14087
+ return true;
14088
+ }
14089
+ current = current.parentListIndex;
14090
+ }
14091
+ return false;
14092
+ }
14093
+ /** ホルダーが生きていて、この listIndex のインスタンスなら記録を返す */
14094
+ function liveInstance(holder, listIndex) {
14095
+ const record = holder.ref.deref();
14096
+ if (typeof record === "undefined" || !isInstanceOf(record, listIndex)) {
14097
+ return null;
14098
+ }
14099
+ return record;
14100
+ }
14101
+ /**
14102
+ * `P.k`(listIndex)に答える記録を引く。索引に無ければ null(今日どおり undefined 解決)。
14103
+ * 複数一致は raise。
14104
+ */
14105
+ function resolveExport(stateElement, parentPath, key, listIndex) {
14106
+ const slot = slotFor(stateElement, parentPath, key, false);
14107
+ if (slot === null) {
14108
+ return null;
14109
+ }
14110
+ const cached = listIndex === null ? slot.noIndex : (slot.byListIndex.get(listIndex) ?? null);
14111
+ if (cached !== null) {
14112
+ const record = liveInstance(cached, listIndex);
14113
+ if (record !== null) {
14114
+ return { record, entry: cached.entry };
14115
+ }
14116
+ }
14117
+ let found = null;
14118
+ let foundRecord = null;
14119
+ for (const holder of slot.holders) {
14120
+ const record = holder.ref.deref();
14121
+ if (typeof record === "undefined") {
14122
+ // 記録は回収済み(finalizer 発火前の窓)— 遅延 prune
14123
+ slot.holders.delete(holder);
14124
+ continue;
14125
+ }
14126
+ if (!isInstanceOf(record, listIndex)) {
14127
+ continue;
14128
+ }
14129
+ if (foundRecord !== null) {
14130
+ raiseError(`[wcs/mount-export-ambiguous] "${parentPath}${DELIMITER}${key}" is exported by two mounted components on the same instance: ` +
14131
+ `<${foundRecord.component.tagName.toLowerCase()}> and <${record.component.tagName.toLowerCase()}>. ` +
14132
+ `Mount only one of them there, or rename one accessor. See docs/state-overlay-export-design.md X4.`);
14133
+ }
14134
+ found = holder;
14135
+ foundRecord = record;
14136
+ }
14137
+ if (found === null || foundRecord === null) {
14138
+ return null;
14139
+ }
14140
+ if (listIndex === null) {
14141
+ slot.noIndex = found;
14142
+ }
14143
+ else {
14144
+ slot.byListIndex.set(listIndex, found);
14145
+ }
14146
+ return { record: foundRecord, entry: found.entry };
14147
+ }
14148
+ /** 公開パスの `$postUpdate` を、記録のホスト要素のループ文脈で打つ(X6)。 */
14149
+ function notifyExports(record) {
14150
+ const parent = record.parentStateElement;
14151
+ const loopContext = getLoopContextByNode(record.component);
14152
+ if (parent.isConnected === false || (record.delta > 0 && loopContext === null)) {
14153
+ // A removed tree needs no notification. A removed row is handled by its
14154
+ // parent's list update. Other notification failures must remain visible.
14155
+ return;
14156
+ }
14157
+ for (const entry of record.exports.values()) {
14158
+ parent.createState("readonly", (state) => {
14159
+ state[setLoopContextSymbol](loopContext, () => {
14160
+ state.$postUpdate(entry.exportedPath);
14161
+ });
14162
+ });
14163
+ }
14164
+ }
14165
+ /**
14166
+ * X1: ツリーに同名キーがある公開 getter は親から読まれない(ツリーが勝つ)。
14167
+ * 登録時に 1 回 warn(タグ × 公開パス)。行マウントはホスト要素のループ文脈で読む。
14168
+ */
14169
+ function warnShadowedExports(record) {
14170
+ const loopContext = getLoopContextByNode(record.component);
14171
+ if (record.delta > 0 && loopContext === null) {
14172
+ // 行マウントでループ文脈が無い(行の実体化前)— 読めないので黙る
14173
+ return;
14174
+ }
14175
+ const tag = record.component.tagName.toLowerCase();
14176
+ for (const entry of record.exports.values()) {
14177
+ const reportKey = `${tag}|${entry.exportedPath}`;
14178
+ if (reportedShadows.has(reportKey)) {
14179
+ continue;
14180
+ }
14181
+ const exportedInfo = getPathInfo(entry.exportedPath);
14182
+ let parentValue = undefined;
14183
+ record.parentStateElement.createState("readonly", (state) => {
14184
+ state[setLoopContextSymbol](loopContext, () => {
14185
+ parentValue = state[exportedInfo.parentPath];
14186
+ });
14187
+ });
14188
+ if (parentValue === null || typeof parentValue === "undefined"
14189
+ || !(exportedInfo.lastSegment in Object(parentValue))) {
14190
+ continue;
14191
+ }
14192
+ reportedShadows.add(reportKey);
14193
+ console.warn(`[@wcstack/state] [wcs/mount-export-shadowed] <${tag}>.${record.stateProp}.${entry.suffix} is exported at ` +
14194
+ `"${entry.exportedPath}" but the tree already has that key, so readers outside the component get the tree value. ` +
14195
+ `Remove the tree key or rename the accessor. See docs/state-overlay-export-design.md X1.`);
14196
+ }
14197
+ }
14198
+
13708
14199
  /**
13709
14200
  * このアドレスの値をキャッシュしてよいか(getByAddress / setByAddress 共通の判定)。
13710
14201
  *
@@ -13839,6 +14330,16 @@ function _getByAddress(target, address, receiver, handler, stateElement) {
13839
14330
  return undefined;
13840
14331
  }
13841
14332
  const lastSegment = address.pathInfo.segments[address.pathInfo.segments.length - 1];
14333
+ // 公開 getter の dispatch(docs/state-overlay-export-design.md §2-1): 掛かるのは
14334
+ // 「ツリーの未存在キー」の分岐だけ(X1 — 命中する読みは無改造)。マウントの無い
14335
+ // state は boolean 1 個で抜ける(D18)
14336
+ if (stateElement.hasMounts === true && lastSegment !== WILDCARD
14337
+ && !(lastSegment in Object(parentValue))) {
14338
+ const exported = resolveExport(stateElement, parentAddress.pathInfo.path, lastSegment, address.listIndex);
14339
+ if (exported !== null) {
14340
+ return readExportedAccessor(exported.record, exported.entry, address.listIndex, receiver, handler);
14341
+ }
14342
+ }
13842
14343
  if (lastSegment === WILDCARD) {
13843
14344
  // listIndex が無いまま末尾ワイルドカードに到達 = そのパスの階数を満たす
13844
14345
  // ループ文脈が無い(`matrix.*.*` を 1 段の `for` の中で読む等)。元の文面は
@@ -14739,6 +15240,8 @@ function _setByAddress(target, address, absAddress, value, receiver, handler, ke
14739
15240
  return Reflect.set(parentValue, index, value);
14740
15241
  }
14741
15242
  else {
15243
+ // 公開 getter への書き込み(X9)は setByAddressCore の fast path(親がオブジェクトの
15244
+ // 未存在キー)で dispatch 済み。ここに来るのは親が非オブジェクトの形だけ
14742
15245
  return Reflect.set(parentValue, lastSegment, value);
14743
15246
  }
14744
15247
  }
@@ -14914,17 +15417,35 @@ function setByAddressCore(target, address, value, receiver, handler, keyedMergeP
14914
15417
  });
14915
15418
  }
14916
15419
  recordWatchPrevValue(stateElement, path, absAddress, devOldValue, devHasOldValue);
15420
+ let dispatchedExport = false;
14917
15421
  try {
14918
15422
  if (key === undefined) {
14919
15423
  // fast path 版の同じ取り違え(末尾ワイルドカードに listIndex が無い)。
14920
15424
  // 通常経路と同じ語彙で「何段必要か」を言う(pathDiagnostics.ts)。
14921
15425
  raiseError(wildcardScopeMessage(`path "${path}"`, address.pathInfo.wildcardCount, address.listIndex?.length ?? 0));
14922
15426
  }
15427
+ // 公開 getter への書き込み(docs/state-overlay-export-design.md X9): 未存在キーへの
15428
+ // 書き込みは今日「ツリーに作る」が、その位置に公開 getter があると以後ツリーが勝ち
15429
+ // (X1)getter を無言で隠す。setter があれば setter、無ければ raise(overlay の set)
15430
+ if (stateElement.hasMounts === true && lastSegment !== WILDCARD && !(key in parentValue)) {
15431
+ const exported = resolveExport(stateElement, address.parentAddress.pathInfo.path, lastSegment, address.listIndex);
15432
+ if (exported !== null) {
15433
+ dispatchedExport = true;
15434
+ return writeExportedAccessor(exported.record, exported.entry, address.listIndex, value, receiver, handler);
15435
+ }
15436
+ }
14923
15437
  return Reflect.set(parentValue, key, value);
14924
15438
  }
14925
15439
  finally {
14926
15440
  notifyWrite(address, absAddress, receiver, handler, keyedMergePath);
14927
- commitWriteCache(stateElement, path, absAddress, value, cacheable);
15441
+ if (dispatchedExport) {
15442
+ // Exported row paths are cacheable but absent from getterPaths. The
15443
+ // accessor may normalize or reject the input; never pin that input.
15444
+ dirtyCacheEntryByAbsoluteStateAddress(absAddress);
15445
+ }
15446
+ else {
15447
+ commitWriteCache(stateElement, path, absAddress, value, cacheable);
15448
+ }
14928
15449
  // DCC bindable イベントディスパッチ(完全一致 + サブパス → 先頭セグメント、§2.1)
14929
15450
  dispatchBindableEvent(stateElement, address.pathInfo, { value });
14930
15451
  }
@@ -15424,6 +15945,27 @@ function updatedCallback(target, refs, receiver, handler) {
15424
15945
  return result;
15425
15946
  }
15426
15947
 
15948
+ /**
15949
+ * errorCallback.ts
15950
+ *
15951
+ * StateClass のライフサイクルフック「$errorCallback」を呼び出すユーティリティ関数。
15952
+ *
15953
+ * 主な役割:
15954
+ * - target に $errorCallback メソッドが定義されていれば、(error, info) で呼び出す
15955
+ * - this は writable な state proxy(receiver)— 作者はここで自分の state にエラーを書ける
15956
+ *
15957
+ * 設計ポイント:
15958
+ * - Reflect.get で安全に取得し、無ければ何もしない(disconnectedCallback と同型)
15959
+ * - 呼び出し元(apply/applyChangeFromBindings.ts)が drain 末尾でまとめて呼び、
15960
+ * callback 自身の throw もそこで隔離する。ここでは await しない
15961
+ */
15962
+ function errorCallback(target, error, info, receiver, _handler) {
15963
+ const callback = Reflect.get(target, STATE_ERROR_CALLBACK_NAME);
15964
+ if (typeof callback === "function") {
15965
+ callback.call(receiver, error, info);
15966
+ }
15967
+ }
15968
+
15427
15969
  /**
15428
15970
  * setLoopContext.ts
15429
15971
  *
@@ -15657,6 +16199,12 @@ function get(target, prop, receiver, handler) {
15657
16199
  };
15658
16200
  break;
15659
16201
  }
16202
+ case errorCallbackSymbol: {
16203
+ api = (error, info) => {
16204
+ return errorCallback(target, error, info, receiver);
16205
+ };
16206
+ break;
16207
+ }
15660
16208
  default: {
15661
16209
  return Reflect.get(target, prop, receiver);
15662
16210
  }
@@ -15975,6 +16523,11 @@ function initializeMountScope(record, scopeRoot) {
15975
16523
  setStateElementAlias(scopeRoot, record.parentStateElement);
15976
16524
  }
15977
16525
  buildMountScopeBindings(record, scopeRoot);
16526
+ // Register exports and alias edges once. Notify parents that evaluated before
16527
+ // registration, including on reinitialization when values may have changed.
16528
+ registerExports(record);
16529
+ warnShadowedExports(record);
16530
+ notifyExports(record);
15978
16531
  setBindingsReadyForScope(scopeRoot, Promise.resolve());
15979
16532
  }
15980
16533
  function buildMountScopeBindings(record, walkRoot) {
@@ -16008,6 +16561,8 @@ function remountScopeBindings(record, scopeRoot) {
16008
16561
  const rebound = session.rebindAddresses();
16009
16562
  // 空でも呼んで良い(ループが回らないだけ)— 分岐を持たない
16010
16563
  applyChangeFromBindings(rebound);
16564
+ // 別の行に付け替わった = その行の公開パスの答えが変わった(X6)
16565
+ notifyExports(record);
16011
16566
  }
16012
16567
 
16013
16568
  /**
@@ -16448,6 +17003,8 @@ class State extends HTMLElementBase {
16448
17003
  }
16449
17004
  __state;
16450
17005
  _hasUpdatedCallback = false;
17006
+ /** $errorCallback の有無(_hasUpdatedCallback と同じく state セット時に確定。ルートのみ) */
17007
+ _hasErrorCallback = false;
16451
17008
  /** enable-ssr のスナップショットから初期化された(D14: ボリュームはデータを採用する) */
16452
17009
  _hydratedFromSsr = false;
16453
17010
  // 他行を読む getter が検出されたリストパス(diff-filter 展開の全行フォールバック対象)。
@@ -16537,6 +17094,7 @@ class State extends HTMLElementBase {
16537
17094
  // パターンは検知できない(bindProperty / _state 再セットは検知する)。
16538
17095
  // ライフサイクルフックは宣言時に定義するのが規約。
16539
17096
  this._hasUpdatedCallback = STATE_UPDATED_CALLBACK_NAME in value;
17097
+ this._hasErrorCallback = STATE_ERROR_CALLBACK_NAME in value;
16540
17098
  // 再 set 時に二重 subscribe しないよう registry をクリアしてから $on を配線し直す。
16541
17099
  clearEventTokenRegistry(this);
16542
17100
  processOnDeclaration(this, value, this._eventTokenNames);
@@ -17136,6 +17694,9 @@ class State extends HTMLElementBase {
17136
17694
  // 台帳エイリアスは消さない(プール再利用の再接続が同じスコープに戻る)。
17137
17695
  // $disconnectedCallback だけは要素のライフサイクルとして呼ぶ(例外は隔離)
17138
17696
  callMountLifecycleCallback(this._mountRecord, "$disconnectedCallback");
17697
+ // 公開 getter の答えが消えた(X6)— 親の依存者を再評価させる。プール返却も
17698
+ // 恒久破棄もここを通る(行ごと消えた形は $postUpdate が届かず無視される)
17699
+ notifyExports(this._mountRecord);
17139
17700
  this._rootNode = null;
17140
17701
  return;
17141
17702
  }
@@ -17384,6 +17945,9 @@ class State extends HTMLElementBase {
17384
17945
  get hasUpdatedCallback() {
17385
17946
  return this._hasUpdatedCallback;
17386
17947
  }
17948
+ get hasErrorCallback() {
17949
+ return this._hasErrorCallback;
17950
+ }
17387
17951
  get crossRowListPaths() {
17388
17952
  return this._crossRowListPaths;
17389
17953
  }
@@ -17743,6 +18307,7 @@ function getWcsManifest() {
17743
18307
  STATE_CONNECTED_CALLBACK_NAME,
17744
18308
  STATE_DISCONNECTED_CALLBACK_NAME,
17745
18309
  STATE_UPDATED_CALLBACK_NAME,
18310
+ STATE_ERROR_CALLBACK_NAME,
17746
18311
  WEBCOMPONENT_STATE_READY_CALLBACK_NAME,
17747
18312
  ],
17748
18313
  reservedStateApi: [
@@ -17889,5 +18454,5 @@ function resolveLiveDeclaration(tag) {
17889
18454
  return { propertyEvents, inputs, commands };
17890
18455
  }
17891
18456
 
17892
- export { Ssr, VERSION, WCS_MANIFEST_VERSION, analyzeContract, bootstrapState, buildBindings, builtinFilterMeta, defineState, getBindingsReady, getConfig, getWcsManifest };
18457
+ export { Ssr, TRUSTED_TYPES_POLICY_SLOT, VERSION, WCS_MANIFEST_VERSION, analyzeContract, bootstrapState, buildBindings, builtinFilterMeta, defineState, getBindingsReady, getConfig, getTrustedTypesPolicy, getWcsManifest, setTrustedTypesPolicy };
17893
18458
  //# sourceMappingURL=index.esm.js.map