@wcstack/state 1.26.0 → 1.28.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
@@ -82,47 +82,6 @@ function setConfig(partialConfig) {
82
82
  }
83
83
  }
84
84
 
85
- const bindingPromiseByNode = new WeakMap();
86
- // resolve 済みマーク。エントリ未生成のまま resolve されたノードは、後から
87
- // wait された時に「生成して即 resolve」で追いつく。
88
- const resolvedNodes = new WeakSet();
89
- let id$1 = 0;
90
- function getInitializeBindingPromiseByNode(node) {
91
- let bindingPromise = bindingPromiseByNode.get(node) || null;
92
- if (bindingPromise !== null) {
93
- return bindingPromise;
94
- }
95
- let resolveFn = undefined;
96
- const promise = new Promise((resolve) => {
97
- resolveFn = resolve;
98
- });
99
- bindingPromise = {
100
- id: ++id$1,
101
- promise,
102
- resolve: resolveFn
103
- };
104
- bindingPromiseByNode.set(node, bindingPromise);
105
- if (resolvedNodes.has(node)) {
106
- bindingPromise.resolve();
107
- }
108
- return bindingPromise;
109
- }
110
- async function waitInitializeBinding(node) {
111
- const bindingPromise = getInitializeBindingPromiseByNode(node);
112
- await bindingPromise.promise;
113
- }
114
- function resolveInitializedBinding(node) {
115
- // ホットパス: リスト行では全 subscriber ノードがここを通るが、await する消費者
116
- // (boundComponent / shadowRoot host)はほぼ居ない。既存エントリが無ければ
117
- // Promise+closure を生成せず resolve 済みマークだけ残す(15 万個級の割り当て削減)。
118
- const existing = bindingPromiseByNode.get(node);
119
- if (typeof existing !== "undefined") {
120
- existing.resolve();
121
- return;
122
- }
123
- resolvedNodes.add(node);
124
- }
125
-
126
85
  const DELIMITER = '.';
127
86
  const WILDCARD = '*';
128
87
  const MAX_WILDCARD_DEPTH = 128;
@@ -130,6 +89,23 @@ const MAX_LOOP_DEPTH = 128;
130
89
  // 因果伝播(Phase 3)の 1 transaction あたり hop 上限。超過分の未処理 record は
131
90
  // quarantine し(適用済みの値は戻さない)、updater から例外は投げない。
132
91
  const MAX_PROPAGATION_HOPS = 32;
92
+ // `$watch` ハンドラ起点の書き込み連鎖の打ち切り深さ(docs/state-watch-hook-design.md §7-2)。
93
+ // watch ハンドラ内の書き込みは新しい microtask バッチを作るため MAX_PROPAGATION_HOPS の
94
+ // ガードが効かず、書き込み先が動的なので `$streams` のような静的な自己依存検出もできない。
95
+ // 値は MAX_PROPAGATION_HOPS と同値だが、別の打ち切り機構なので定数は共有しない。
96
+ const MAX_WATCH_CHAIN_DEPTH = 32;
97
+ // updater の drain 終了リスナーの実行順(昇順に呼ばれる。設計書 §3-2 層 1)。
98
+ // watch が先なのは、watch ハンドラの書き込みが同じバッチの stream restart 判定に
99
+ // 影響しないようにするため(watch → restart の一方向)。import 順に順序を持たせると
100
+ // 無関係な import 整理で静かに壊れるため、明示的な優先度で固定する。
101
+ //
102
+ // devtools が最も先なのは、`state:update-batch` が「そのバッチに何が載ったか」の
103
+ // 観測であり、watch / restart の副作用が乗る前の生の集合を報告すべきだから。
104
+ // 既定値 0 のまま暗黙に先頭へ入るのに任せず、意図として定数で固定する
105
+ // (docs/devtools-hook-protocol.md §4.3)。
106
+ const DEVTOOLS_LISTENER_PRIORITY = 0;
107
+ const WATCH_LISTENER_PRIORITY = 10;
108
+ const STREAM_LISTENER_PRIORITY = 20;
133
109
  // data-wcs バインディング構文 `[prop][#mod]: [path][@state][|filter...]` の区切り文字(単一正本)。
134
110
  // これらは「死守の壁(構文契約)」であり値は不変。manifest.syntax.delimiters で公開される。
135
111
  const BINDING_SEPARATOR = ';'; // 複数バインディングの区切り
@@ -137,6 +113,38 @@ const PROP_VALUE_SEPARATOR = ':'; // 左辺(prop)と右辺(path)の区切り
137
113
  const MODIFIER_SEPARATOR = '#'; // prop と修飾子の区切り
138
114
  const STATE_NAME_SEPARATOR = '@'; // path と @stateName の区切り
139
115
  const FILTER_SEPARATOR = '|'; // フィルタパイプの区切り
116
+ // 修飾子(`#` 後)の語彙(単一正本)。manifest.syntax.modifiers で公開される。
117
+ // フラグ形(`#prevent` — 値を取らない)とキー値形(`#init=element` — `=` で値を取る)。
118
+ // 消費箇所(event/handler・BindingSession・twowayHandler・bindings/initialSync)は
119
+ // この定数を参照する — 文字列リテラルの散在は tooling への収載漏れの温床だった
120
+ // (docs/static-wiring-dx-design.md §2-2)。
121
+ const MODIFIER_PREVENT = 'prevent';
122
+ const MODIFIER_STOP = 'stop';
123
+ const MODIFIER_READONLY = 'ro';
124
+ const MODIFIER_FLAGS = Object.freeze([
125
+ MODIFIER_PREVENT, MODIFIER_STOP, MODIFIER_READONLY,
126
+ ]);
127
+ const MODIFIER_KEY_INIT = 'init';
128
+ const MODIFIER_KEY_SYNC = 'sync';
129
+ const MODIFIER_KEYS = Object.freeze([
130
+ MODIFIER_KEY_INIT, MODIFIER_KEY_SYNC,
131
+ ]);
132
+ // bindingType 判別と左辺 namespace の語彙(単一正本)。manifest.syntax.bindingTypes で
133
+ // 公開される。パーサ(parseBindTextsForElement)とイベント層はこの定数に分岐する。
134
+ // apply 層のディスパッチマップ(apply/applyChange.ts の applyChangeByFirstSegment)の
135
+ // キー集合との一致は __tests__/manifest.test.ts の drift テストが強制する —
136
+ // manifest エントリ(DOM 非依存)から apply 層を import しないための分離。
137
+ const ELSE_KEYWORD = 'else';
138
+ const SPREAD_PROP = '...';
139
+ const EVENT_PROP_PREFIX = 'on';
140
+ const EVENT_TOKEN_NAMESPACE = 'eventToken';
141
+ const COMMAND_NAMESPACE = 'command';
142
+ const CLASS_NAMESPACE = 'class';
143
+ const ATTR_NAMESPACE = 'attr';
144
+ const STYLE_NAMESPACE = 'style';
145
+ // リストインデックス参照名(`$1`..`$N`)の接頭辞(単一正本)。
146
+ // manifest.syntax.indexParam で公開される。
147
+ const INDEX_PARAM_PREFIX = '$';
140
148
  /**
141
149
  * stackIndexByIndexName
142
150
  * インデックス名からスタックインデックスへのマッピング
@@ -148,7 +156,7 @@ const FILTER_SEPARATOR = '|'; // フィルタパイプの区切り
148
156
  */
149
157
  const tmpIndexByIndexName = {};
150
158
  for (let i = 0; i < MAX_WILDCARD_DEPTH; i++) {
151
- tmpIndexByIndexName[`$${i + 1}`] = i;
159
+ tmpIndexByIndexName[`${INDEX_PARAM_PREFIX}${i + 1}`] = i;
152
160
  }
153
161
  const INDEX_BY_INDEX_NAME = Object.freeze(tmpIndexByIndexName);
154
162
  const NO_SET_TIMEOUT = 60 * 1000; // 1分
@@ -164,11 +172,53 @@ const STATE_COMMAND_NAMESPACE_NAME = "$command";
164
172
  const STATE_EVENT_TOKENS_NAME = "$eventTokens";
165
173
  const STATE_ON_NAME = "$on";
166
174
  const STATE_STREAMS_NAME = "$streams";
175
+ const STATE_WATCH_NAME = "$watch";
167
176
  const STATE_LIST_KEYS_NAME = "$listKeys";
168
177
  const STATE_STREAM_STATUS_NAMESPACE_NAME = "$streamStatus";
169
178
  const STATE_STREAM_ERROR_NAMESPACE_NAME = "$streamError";
170
179
  const DCC_DEFINITION_ATTRIBUTE = "data-wc-definition";
171
180
 
181
+ const bindingPromiseByNode = new WeakMap();
182
+ // resolve 済みマーク。エントリ未生成のまま resolve されたノードは、後から
183
+ // wait された時に「生成して即 resolve」で追いつく。
184
+ const resolvedNodes = new WeakSet();
185
+ let id$1 = 0;
186
+ function getInitializeBindingPromiseByNode(node) {
187
+ let bindingPromise = bindingPromiseByNode.get(node) || null;
188
+ if (bindingPromise !== null) {
189
+ return bindingPromise;
190
+ }
191
+ let resolveFn = undefined;
192
+ const promise = new Promise((resolve) => {
193
+ resolveFn = resolve;
194
+ });
195
+ bindingPromise = {
196
+ id: ++id$1,
197
+ promise,
198
+ resolve: resolveFn
199
+ };
200
+ bindingPromiseByNode.set(node, bindingPromise);
201
+ if (resolvedNodes.has(node)) {
202
+ bindingPromise.resolve();
203
+ }
204
+ return bindingPromise;
205
+ }
206
+ async function waitInitializeBinding(node) {
207
+ const bindingPromise = getInitializeBindingPromiseByNode(node);
208
+ await bindingPromise.promise;
209
+ }
210
+ function resolveInitializedBinding(node) {
211
+ // ホットパス: リスト行では全 subscriber ノードがここを通るが、await する消費者
212
+ // (boundComponent / shadowRoot host)はほぼ居ない。既存エントリが無ければ
213
+ // Promise+closure を生成せず resolve 済みマークだけ残す(15 万個級の割り当て削減)。
214
+ const existing = bindingPromiseByNode.get(node);
215
+ if (typeof existing !== "undefined") {
216
+ existing.resolve();
217
+ return;
218
+ }
219
+ resolvedNodes.add(node);
220
+ }
221
+
172
222
  const _cache$4 = new Map();
173
223
  let id = 0;
174
224
  function getPathInfo(path) {
@@ -622,6 +672,75 @@ const STRUCTURAL_BINDING_TYPE_SET = new Set([
622
672
  "for",
623
673
  ]);
624
674
 
675
+ /**
676
+ * errorGuidance.ts — エラーメッセージへの self-fix 誘導(GTM 2-5 /
677
+ * docs/static-wiring-dx-design.md §3)。
678
+ *
679
+ * コンソールは「書き手(人間・AI とも)が誤った瞬間に必ず読む面」なので、
680
+ * (a) did-you-mean 候補 (b) lint への誘導 をエラーメッセージ自体に埋め込む。
681
+ * ここの関数は全て**エラーパスでのみ**呼ばれる — 正常系のコストはゼロ。
682
+ * auto.min.js に同梱されるため文字列は最小限に保つ(エラーパス専用モジュールの
683
+ * 遅延 import は `src/auto.ts` の SRI 自己完結制約で不可)。
684
+ *
685
+ * 診断 code の語彙はコンソール → lint → IDE の三面で共有する:
686
+ * メッセージ先頭の `[wcs/...]` は wcstack-intellisense / @wcstack/lint の
687
+ * 安定診断 code(packages/vscode-wcs/src/core/diagnostics.ts)と同一。
688
+ */
689
+ /** 挿入・削除・置換の編集距離。長さ差が max を超えたら早期に max+1 を返す。 */
690
+ function editDistance(a, b, max) {
691
+ if (Math.abs(a.length - b.length) > max) {
692
+ return max + 1;
693
+ }
694
+ const prev = new Array(b.length + 1);
695
+ const curr = new Array(b.length + 1);
696
+ for (let j = 0; j <= b.length; j++) {
697
+ prev[j] = j;
698
+ }
699
+ for (let i = 1; i <= a.length; i++) {
700
+ curr[0] = i;
701
+ for (let j = 1; j <= b.length; j++) {
702
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
703
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
704
+ }
705
+ for (let j = 0; j <= b.length; j++) {
706
+ prev[j] = curr[j];
707
+ }
708
+ }
709
+ return prev[b.length];
710
+ }
711
+ /**
712
+ * 候補集合から編集距離 2 以内の最近傍を探し、` Did you mean "<best>"?` を返す。
713
+ * 該当なしは空文字。規準(距離 2・同距離は先勝ち・大小文字は畳んで比較)は
714
+ * lint の did-you-mean(ioNodeValidator の suggestion)と同じ — 三面で提案が
715
+ * 割れないように揃えている。動的キー等で候補が列挙できないサイトでは呼ばない
716
+ * = 誘導文のみに縮退(設計 §3 の縮退)。
717
+ */
718
+ function didYouMean(input, candidates) {
719
+ // 空入力(`a|` の末尾パイプ等)に短い候補を提案しても無意味なので出さない。
720
+ if (input.length === 0) {
721
+ return "";
722
+ }
723
+ const folded = input.toLowerCase();
724
+ let best = null;
725
+ let bestDistance = 3;
726
+ for (const candidate of candidates) {
727
+ const distance = editDistance(folded, candidate.toLowerCase(), 2);
728
+ if (distance < bestDistance) {
729
+ best = candidate;
730
+ bestDistance = distance;
731
+ }
732
+ }
733
+ return best !== null ? ` Did you mean "${best}"?` : "";
734
+ }
735
+ /**
736
+ * lint への誘導(誘導付きメッセージ共通の一文)。
737
+ * **lint が実際にそのケースを検出するサイトにだけ付ける** — 検出しないケースに
738
+ * 付けると「エラー → lint 実行 → clean」の空振りで検証ループの信頼を毀損する
739
+ * (DCC 宣言・watch の一部 shape・構造型単独バインディング違反は lint 未検出のため
740
+ * 付けない。lint 側への検査追加は follow-up)。
741
+ */
742
+ const LINT_HINT = " Validate statically: npx @wcstack/lint <file>.";
743
+
625
744
  /**
626
745
  * errorMessages.ts
627
746
  *
@@ -683,6 +802,15 @@ function valueMustBeBoolean(fnName) {
683
802
  function valueMustBeDate(fnName) {
684
803
  raiseError(`filter ${fnName} requires a date value`);
685
804
  }
805
+ /**
806
+ * Throws error when filter requires array value but non-array provided.
807
+ *
808
+ * @param fnName - Name of the filter function
809
+ * @returns Never returns (always throws)
810
+ */
811
+ function valueMustBeArray(fnName) {
812
+ raiseError(`filter ${fnName} requires an array value`);
813
+ }
686
814
 
687
815
  /**
688
816
  * builtinFilters.ts
@@ -695,7 +823,7 @@ function valueMustBeDate(fnName) {
695
823
  * - Designed for common use as both input and output filters
696
824
  *
697
825
  * Design points:
698
- * - Comprehensive coverage of diverse filters: eq, ne, lt, gt, inc, fix, locale, uc, lc, cap, trim, slice, pad, int, float, round, date, time, ymd, falsy, truthy, defaults, boolean, number, string, null, etc.
826
+ * - Comprehensive coverage of diverse filters: eq, ne, lt, gt, inc, abs, clamp, fix, locale, uc, lc, cap, trim, slice, pad, truncate, join, int, float, round, percent, unit, date, time, ymd, hms, falsy, truthy, defaults, boolean, number, string, null, etc.
699
827
  * - Rich type checking and error handling for option values
700
828
  * - Centralized management of filter functions with FilterWithOptions type, easy to extend
701
829
  * - Dynamic retrieval of filter functions from filter names and options via builtinFilterFn
@@ -928,6 +1056,48 @@ const mod = (options) => {
928
1056
  return value % Number(opt);
929
1057
  };
930
1058
  };
1059
+ /**
1060
+ * Absolute value filter - returns the magnitude of a number.
1061
+ *
1062
+ * @param options - Unused
1063
+ * @returns Filter function that returns the absolute value
1064
+ */
1065
+ const abs = (_options) => {
1066
+ return (value) => {
1067
+ if (typeof value !== 'number') {
1068
+ valueMustBeNumber('abs');
1069
+ }
1070
+ return Math.abs(value);
1071
+ };
1072
+ };
1073
+ /**
1074
+ * Clamp filter - constrains a number to the inclusive range [min, max].
1075
+ *
1076
+ * Saturating conversion in the same family as round/floor/ceil, so it stays on
1077
+ * the wire rather than in state. Pairs with `unit` for style bindings:
1078
+ * `style.width: ratio|clamp(0,1)|percent(0)`.
1079
+ *
1080
+ * @param options - Array with minimum as first element and maximum as second (both required)
1081
+ * @returns Filter function that returns the clamped number
1082
+ */
1083
+ const clamp = (options) => {
1084
+ const opt1 = options?.[0] ?? optionsRequired('clamp');
1085
+ if (!validateNumberString(opt1)) {
1086
+ optionMustBeNumber('clamp');
1087
+ }
1088
+ const opt2 = options?.[1] ?? optionsRequired('clamp');
1089
+ if (!validateNumberString(opt2)) {
1090
+ optionMustBeNumber('clamp');
1091
+ }
1092
+ const min = Number(opt1);
1093
+ const max = Number(opt2);
1094
+ return (value) => {
1095
+ if (typeof value !== 'number') {
1096
+ valueMustBeNumber('clamp');
1097
+ }
1098
+ return Math.min(Math.max(value, min), max);
1099
+ };
1100
+ };
931
1101
  /**
932
1102
  * Fixed decimal filter - formats number to fixed decimal places.
933
1103
  *
@@ -1194,6 +1364,76 @@ const percent = (options) => {
1194
1364
  return `${(value * 100).toFixed(Number(opt))}%`;
1195
1365
  };
1196
1366
  };
1367
+ /**
1368
+ * Unit filter - appends a CSS unit (or any suffix) to the value.
1369
+ *
1370
+ * A number alone does nothing in CSS, so without this the unit has to be built in
1371
+ * state — which drags presentation into the source of truth, and in the worst case
1372
+ * forces a whole derived array just to carry `"42%"` strings.
1373
+ * `style.height: samples.*.cpu|clamp(0,100)|fix(0)|unit(%)` keeps it on the wire.
1374
+ *
1375
+ * Accepts strings as well as numbers **on purpose**: the useful chains run through
1376
+ * `fix` / `percent`, which already return strings. Rejecting non-numbers here would
1377
+ * break exactly the combination this filter exists for.
1378
+ *
1379
+ * `null` / `undefined` pass through untouched rather than becoming `"undefinedpx"`,
1380
+ * so the binding layer's "undefined skips the write, null clears" semantics survive.
1381
+ *
1382
+ * @param options - Array with the unit/suffix as first element (required)
1383
+ * @returns Filter function that returns the value with the unit appended
1384
+ */
1385
+ const unit = (options) => {
1386
+ const opt = options?.[0] ?? optionsRequired('unit');
1387
+ return (value) => {
1388
+ if (value === null || typeof value === 'undefined') {
1389
+ return value;
1390
+ }
1391
+ return String(value) + opt;
1392
+ };
1393
+ };
1394
+ /**
1395
+ * Join filter - joins array elements into a string.
1396
+ *
1397
+ * The default separator is `", "` rather than `","`: a bare comma is what `String()`
1398
+ * already produces without any filter, so defaulting to it would make `|join` a no-op.
1399
+ *
1400
+ * @param options - Array with separator as first element (default: ', ')
1401
+ * @returns Filter function that returns the joined string
1402
+ */
1403
+ const join = (options) => {
1404
+ const opt = options?.[0] ?? ', ';
1405
+ return (value) => {
1406
+ if (!Array.isArray(value)) {
1407
+ valueMustBeArray('join');
1408
+ }
1409
+ return value.join(opt);
1410
+ };
1411
+ };
1412
+ /**
1413
+ * Truncate filter - shortens a string and appends an ellipsis.
1414
+ *
1415
+ * The length option counts **kept characters**, not the total including the suffix,
1416
+ * matching the existing `slice(0, n)` reading. A string at or below the limit is
1417
+ * returned untouched (no suffix).
1418
+ *
1419
+ * @param options - Array with max kept length as first element and suffix as second (default: '…')
1420
+ * @returns Filter function that returns the truncated string
1421
+ */
1422
+ const truncate = (options) => {
1423
+ const opt1 = options?.[0] ?? optionsRequired('truncate');
1424
+ if (!validateNumberString(opt1)) {
1425
+ optionMustBeNumber('truncate');
1426
+ }
1427
+ const maxLength = Number(opt1);
1428
+ const suffix = options?.[1] ?? '…';
1429
+ return (value) => {
1430
+ const v = String(value);
1431
+ if (v.length <= maxLength) {
1432
+ return v;
1433
+ }
1434
+ return v.slice(0, maxLength) + suffix;
1435
+ };
1436
+ };
1197
1437
  /**
1198
1438
  * Date filter - formats Date object as localized date string.
1199
1439
  *
@@ -1257,6 +1497,27 @@ const ymd = (options) => {
1257
1497
  return `${year}${opt}${month}${opt}${day}`;
1258
1498
  };
1259
1499
  };
1500
+ /**
1501
+ * Hour-Minute-Second filter - formats Date object as HH:MM:SS string.
1502
+ *
1503
+ * The counterpart of `ymd`: a fixed, zero-padded, locale-independent rendering with a
1504
+ * configurable separator, for when `time` (locale-formatted) is not stable enough.
1505
+ *
1506
+ * @param options - Array with separator string as first element (default: ':')
1507
+ * @returns Filter function that returns formatted time string
1508
+ */
1509
+ const hms = (options) => {
1510
+ const opt = options?.[0] ?? ':';
1511
+ return (value) => {
1512
+ if (!(value instanceof Date)) {
1513
+ valueMustBeDate('hms');
1514
+ }
1515
+ const hours = value.getHours().toString().padStart(2, '0');
1516
+ const minutes = value.getMinutes().toString().padStart(2, '0');
1517
+ const seconds = value.getSeconds().toString().padStart(2, '0');
1518
+ return `${hours}${opt}${minutes}${opt}${seconds}`;
1519
+ };
1520
+ };
1260
1521
  /**
1261
1522
  * Falsy filter - checks if value is falsy.
1262
1523
  *
@@ -1347,6 +1608,8 @@ const builtinFilters = {
1347
1608
  "mul": mul,
1348
1609
  "div": div,
1349
1610
  "mod": mod,
1611
+ "abs": abs,
1612
+ "clamp": clamp,
1350
1613
  "fix": fix,
1351
1614
  "locale": locale,
1352
1615
  "uc": uc,
@@ -1358,16 +1621,20 @@ const builtinFilters = {
1358
1621
  "pad": pad,
1359
1622
  "rep": rep,
1360
1623
  "rev": rev,
1624
+ "truncate": truncate,
1625
+ "join": join,
1361
1626
  "int": int,
1362
1627
  "float": float,
1363
1628
  "round": round,
1364
1629
  "floor": floor,
1365
1630
  "ceil": ceil,
1366
1631
  "percent": percent,
1632
+ "unit": unit,
1367
1633
  "date": date,
1368
1634
  "time": time,
1369
1635
  "datetime": datetime,
1370
1636
  "ymd": ymd,
1637
+ "hms": hms,
1371
1638
  "falsy": falsy,
1372
1639
  "truthy": truthy,
1373
1640
  "defaults": defaults,
@@ -1392,16 +1659,50 @@ const builtinFiltersByFilterIOType = {
1392
1659
  const builtinFilterFn = (name, options) => (filters) => {
1393
1660
  const filter = filters[name];
1394
1661
  if (!filter) {
1395
- raiseError(`filter not found: ${name}`);
1662
+ // lint の wcs/filter-unknown と同じ語彙・同じ did-you-mean 規準(三面同語彙)。
1663
+ raiseError(`[wcs/filter-unknown] filter not found: ${name}.${didYouMean(name, Object.keys(filters))}${LINT_HINT}`);
1396
1664
  }
1397
1665
  return filter(options);
1398
1666
  };
1399
1667
 
1668
+ /**
1669
+ * フィルタ引数リストのパース。`filter(a, b)` の `a, b` 部分を受け取る。
1670
+ *
1671
+ * トリムの規則は「**クォートの外側だけ**」。`fix( 2 )` のような書き癖を吸収するために
1672
+ * 素の引数は前後をトリムするが、クォートは「ここは literal」という宣言なので中身の
1673
+ * 空白は残す。両方まとめてトリムしていたため `pad(5, ' ')` が空文字パディング
1674
+ * (=無変化)に化けており、空白区切りの `join(' / ')` も指定できなかった。
1675
+ */
1676
+ /** 引数 1 つを確定する。クォート由来の文字が入った範囲より外側だけをトリムする。 */
1677
+ function finalizeArg(text, firstQuoteStart, lastQuoteEnd) {
1678
+ // 先頭側: 最初のクォート文字より前だけが削れる(クォートが無ければ全体が対象)
1679
+ const startLimit = firstQuoteStart === -1 ? text.length : firstQuoteStart;
1680
+ let start = 0;
1681
+ while (start < startLimit && /\s/.test(text[start])) {
1682
+ start++;
1683
+ }
1684
+ // 末尾側: 最後のクォート文字より後ろだけが削れる(クォートが無ければ全体が対象)
1685
+ const endLimit = lastQuoteEnd === -1 ? 0 : lastQuoteEnd;
1686
+ let end = text.length;
1687
+ while (end > endLimit && /\s/.test(text[end - 1])) {
1688
+ end--;
1689
+ }
1690
+ return text.slice(start, end);
1691
+ }
1400
1692
  function parseFilterArgs(argsText) {
1401
1693
  const args = [];
1402
1694
  let current = '';
1403
1695
  let inQuote = null;
1404
1696
  let hasQuote = false;
1697
+ let firstQuoteStart = -1;
1698
+ let lastQuoteEnd = -1;
1699
+ const flush = () => {
1700
+ args.push(finalizeArg(current, firstQuoteStart, lastQuoteEnd));
1701
+ current = '';
1702
+ hasQuote = false;
1703
+ firstQuoteStart = -1;
1704
+ lastQuoteEnd = -1;
1705
+ };
1405
1706
  for (let i = 0; i < argsText.length; i++) {
1406
1707
  const char = argsText[i];
1407
1708
  if (inQuote) {
@@ -1409,7 +1710,11 @@ function parseFilterArgs(argsText) {
1409
1710
  inQuote = null;
1410
1711
  }
1411
1712
  else {
1713
+ if (firstQuoteStart === -1) {
1714
+ firstQuoteStart = current.length;
1715
+ }
1412
1716
  current += char;
1717
+ lastQuoteEnd = current.length;
1413
1718
  }
1414
1719
  }
1415
1720
  else if (char === '"' || char === "'") {
@@ -1417,15 +1722,13 @@ function parseFilterArgs(argsText) {
1417
1722
  hasQuote = true;
1418
1723
  }
1419
1724
  else if (char === ',') {
1420
- args.push(current.trim());
1421
- current = '';
1422
- hasQuote = false;
1725
+ flush();
1423
1726
  }
1424
1727
  else {
1425
1728
  current += char;
1426
1729
  }
1427
1730
  }
1428
- const last = current.trim();
1731
+ const last = finalizeArg(current, firstQuoteStart, lastQuoteEnd);
1429
1732
  if (last || hasQuote) {
1430
1733
  args.push(last);
1431
1734
  }
@@ -1578,11 +1881,11 @@ function parseBindTextsForElement(bindText) {
1578
1881
  }
1579
1882
  const propPart = bindText.slice(0, separatorIndex).trim();
1580
1883
  const statePart = bindText.slice(separatorIndex + 1).trim();
1581
- if (propPart === 'else') {
1884
+ if (propPart === ELSE_KEYWORD) {
1582
1885
  const pathInfo = getPathInfo('#else');
1583
1886
  return {
1584
- propName: 'else',
1585
- propSegments: ['else'],
1887
+ propName: ELSE_KEYWORD,
1888
+ propSegments: [ELSE_KEYWORD],
1586
1889
  propModifiers: [],
1587
1890
  statePathName: '#else',
1588
1891
  statePathInfo: pathInfo,
@@ -1592,7 +1895,7 @@ function parseBindTextsForElement(bindText) {
1592
1895
  bindingType: 'else',
1593
1896
  };
1594
1897
  }
1595
- else if (propPart === '...') {
1898
+ else if (propPart === SPREAD_PROP) {
1596
1899
  const stateResult = parseStatePart(statePart);
1597
1900
  if (stateResult.outFilters.length > 0) {
1598
1901
  raiseError(`Invalid spread binding "${bindText}": filters are not allowed on spread targets.`);
@@ -1601,8 +1904,8 @@ function parseBindTextsForElement(bindText) {
1601
1904
  raiseError(`Invalid spread binding "${bindText}": spread target path is required.`);
1602
1905
  }
1603
1906
  return {
1604
- propName: '...',
1605
- propSegments: ['...'],
1907
+ propName: SPREAD_PROP,
1908
+ propSegments: [SPREAD_PROP],
1606
1909
  propModifiers: [],
1607
1910
  inFilters: [],
1608
1911
  ...stateResult,
@@ -1629,14 +1932,14 @@ function parseBindTextsForElement(bindText) {
1629
1932
  const propResult = parsePropPart(propPart);
1630
1933
  // eventToken.<prop>: <name> は要素 dispatch を state へ流す pub/sub 配線。
1631
1934
  // 値適用ではないため bindingType 'event' として listener attach 経路に乗せる。
1632
- if (propResult.propSegments[0] === 'eventToken') {
1935
+ if (propResult.propSegments[0] === EVENT_TOKEN_NAMESPACE) {
1633
1936
  return {
1634
1937
  ...propResult,
1635
1938
  ...stateResult,
1636
1939
  bindingType: 'event',
1637
1940
  };
1638
1941
  }
1639
- if (propResult.propSegments[0].startsWith('on')) {
1942
+ if (propResult.propSegments[0].startsWith(EVENT_PROP_PREFIX)) {
1640
1943
  return {
1641
1944
  ...propResult,
1642
1945
  ...stateResult,
@@ -1656,7 +1959,9 @@ function parseBindTextsForElement(bindText) {
1656
1959
  if (results.length > 1) {
1657
1960
  const isIncludeSingleBinding = results.some(r => STRUCTURAL_BINDING_TYPE_SET.has(r.bindingType));
1658
1961
  if (isIncludeSingleBinding) {
1659
- raiseError(`Invalid bindText: "${bindText}". 'if', 'elseif', 'else', and 'for' bindings must be single binding.`);
1962
+ // LINT_HINT は付けない: 単独バインディング検査は lint 側に未実装で、誘導が
1963
+ // 空振りする(lint への検査追加は follow-up)。
1964
+ raiseError(`[wcs/template-syntax] Invalid bindText: "${bindText}". 'if', 'elseif', 'else', and 'for' bindings must be single binding. Put the structural binding alone in its own data-wcs (e.g. <template data-wcs="for: items">).`);
1660
1965
  }
1661
1966
  }
1662
1967
  return results;
@@ -1778,13 +2083,97 @@ function getParseBindTextResults(node) {
1778
2083
  return [];
1779
2084
  }
1780
2085
 
2086
+ /**
2087
+ * bindings/lightDomComponentScope.ts — Light DOM の mapped `bind-component` を
2088
+ * 「ホストとは別のバインディングスコープ」として扱うための判定
2089
+ * (docs/architecture-hardening/15-state-component-mechanism-consistency.md §1.13)。
2090
+ *
2091
+ * Shadow DOM 形では、コンポーネントの `<wcs-state>` が**別 rootNode** にいることで
2092
+ * 2 つのことが同時に成立している。
2093
+ *
2094
+ * 1. ホスト root の `waitForStateInitialize` の走査集合に入らない
2095
+ * 2. 子スコープのバインディングがホストとは別の `buildBindings` パスで処理される
2096
+ *
2097
+ * Light DOM では両方が失われる。1 が失われると、
2098
+ * 「ホストの `waitForStateInitialize` が子 state を待つ →
2099
+ * 子 state は自分を束ねるホスト binding を待つ →
2100
+ * その binding を作る `initializeBindings` は `waitForStateInitialize` の後」
2101
+ * という循環になり、初期化が永久に解決しない。2 が失われると、子スコープの
2102
+ * `@name` 参照がホストと同じパスで解決されてしまい、子 state の名前登録より
2103
+ * 先に評価される。
2104
+ *
2105
+ * このモジュールはその 2 つを明示的に復元するための判定だけを持つ。
2106
+ *
2107
+ * **plain(ホストからバインドしない state 注入)は対象外**であることに注意。
2108
+ * plain は `waitInitializeBinding` を通らないので循環せず、従来どおりホストと
2109
+ * 同じパスで初期化して問題ない。ここで一律に切り出すと、成立している plain 形が
2110
+ * 「子 state の登録前に `@name` を解決する」形に退行する。
2111
+ */
2112
+ /** `<wcs-state bind-component>` が Light DOM の mapped 形(=別スコープ扱い)か。 */
2113
+ function isLightDomMappedStateElement(stateElement) {
2114
+ if (!stateElement.hasAttribute("bind-component")) {
2115
+ return false;
2116
+ }
2117
+ const parentNode = stateElement.parentNode;
2118
+ // Shadow DOM 形では parentNode が ShadowRoot になる(かつホスト root の
2119
+ // querySelectorAll にはそもそも出てこない)
2120
+ if (!(parentNode instanceof Element)) {
2121
+ return false;
2122
+ }
2123
+ // ホストからバインドされていなければ plain。従来どおりの扱いに任せる
2124
+ return parentNode.hasAttribute(config.bindAttributeName);
2125
+ }
2126
+ /**
2127
+ * `root` の内側にある Light DOM mapped コンポーネント要素を集める。
2128
+ *
2129
+ * `root` 自身は**含めない**。子スコープが自分のパスとして
2130
+ * `initializeBindings(componentElement)` を呼ぶとき、その要素自身まで prune すると
2131
+ * 何も初期化されなくなるため。
2132
+ */
2133
+ function findNestedLightDomComponents(root) {
2134
+ const components = [];
2135
+ const stateElements = root.querySelectorAll(`${config.tagNames.state}[bind-component]`);
2136
+ for (const stateElement of stateElements) {
2137
+ if (!isLightDomMappedStateElement(stateElement)) {
2138
+ continue;
2139
+ }
2140
+ const component = stateElement.parentNode;
2141
+ if (component === root) {
2142
+ continue;
2143
+ }
2144
+ components.push(component);
2145
+ }
2146
+ return components;
2147
+ }
2148
+ /** `node` が、いずれかのコンポーネント要素の**真の**子孫か。 */
2149
+ function isInsideAnyComponent(node, components) {
2150
+ for (let i = 0; i < components.length; i++) {
2151
+ const component = components[i];
2152
+ if (component !== node && component.contains(node)) {
2153
+ return true;
2154
+ }
2155
+ }
2156
+ return false;
2157
+ }
2158
+
1781
2159
  /**
1782
2160
  * data-wcs 属性または埋め込みノード<!--{{}}-->を持つノードをすべて取得する
2161
+ *
2162
+ * Light DOM の mapped コンポーネントの**内側**は除外する(§1.13)。そのサブツリーの
2163
+ * `@name` 参照は、コンポーネント側の state が名前登録を済ませてからでないと解決できず、
2164
+ * ホストと同じパスで拾うと登録前に評価されてしまう。除外したぶんは、その state が
2165
+ * 初期化を終えた時点で自分のスコープとして `initializeBindings(componentElement)` を
2166
+ * 呼び直す(Shadow DOM 形で rootNode ごとにパスが分かれるのと同じ形にする)。
2167
+ *
2168
+ * コンポーネント要素**自身**は除外しない。ホスト側の `data-wcs`(`state.msg: user.name`)
2169
+ * はホストのスコープに属し、それが張られることで子側の待ちが解ける。
2170
+ *
1783
2171
  * @param root
1784
2172
  * @returns
1785
2173
  */
1786
2174
  function getSubscriberNodes(root) {
1787
2175
  const subscriberNodes = [];
2176
+ const nestedComponents = findNestedLightDomComponents(root);
1788
2177
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT, {
1789
2178
  acceptNode(node) {
1790
2179
  if (node.nodeType === Node.ELEMENT_NODE) {
@@ -1803,7 +2192,14 @@ function getSubscriberNodes(root) {
1803
2192
  }
1804
2193
  });
1805
2194
  while (walker.nextNode()) {
1806
- subscriberNodes.push(walker.currentNode);
2195
+ const node = walker.currentNode;
2196
+ // TreeWalker の acceptNode は「自分は拾うが子孫は辿らない」を表現できないため、
2197
+ // コンポーネント要素自身を拾ったうえで、その子孫をここで落とす。
2198
+ // nestedComponents が空(=圧倒的多数)のときは contains 走査ごと発生しない。
2199
+ if (nestedComponents.length > 0 && isInsideAnyComponent(node, nestedComponents)) {
2200
+ continue;
2201
+ }
2202
+ subscriberNodes.push(node);
1807
2203
  }
1808
2204
  return subscriberNodes;
1809
2205
  }
@@ -2441,8 +2837,8 @@ function getHandlerKey$3(binding, eventName) {
2441
2837
  function getEventName$2(binding) {
2442
2838
  let eventName = 'input';
2443
2839
  for (const modifier of binding.propModifiers) {
2444
- if (modifier.startsWith('on')) {
2445
- eventName = modifier.slice(2);
2840
+ if (modifier.startsWith(EVENT_PROP_PREFIX)) {
2841
+ eventName = modifier.slice(EVENT_PROP_PREFIX.length);
2446
2842
  }
2447
2843
  }
2448
2844
  return eventName;
@@ -2497,7 +2893,7 @@ const checkboxEventHandlerFunction = (stateName, statePathName, inFilters) => (e
2497
2893
  });
2498
2894
  };
2499
2895
  function attachCheckboxEventHandler(binding) {
2500
- if (binding.bindingType === "checkbox" && binding.propModifiers.indexOf('ro') === -1) {
2896
+ if (binding.bindingType === "checkbox" && binding.propModifiers.indexOf(MODIFIER_READONLY) === -1) {
2501
2897
  const eventName = getEventName$2(binding);
2502
2898
  const key = getHandlerKey$3(binding, eventName);
2503
2899
  let checkboxEventHandler = handlerByHandlerKey$3.get(key);
@@ -2512,7 +2908,7 @@ function attachCheckboxEventHandler(binding) {
2512
2908
  return false;
2513
2909
  }
2514
2910
  function detachCheckboxEventHandler(binding) {
2515
- if (binding.bindingType === "checkbox" && binding.propModifiers.indexOf('ro') === -1) {
2911
+ if (binding.bindingType === "checkbox" && binding.propModifiers.indexOf(MODIFIER_READONLY) === -1) {
2516
2912
  const eventName = getEventName$2(binding);
2517
2913
  const key = getHandlerKey$3(binding, eventName);
2518
2914
  const checkboxEventHandler = handlerByHandlerKey$3.get(key);
@@ -2642,12 +3038,12 @@ class EventToken extends Token {
2642
3038
  }
2643
3039
  }
2644
3040
 
2645
- const registryByStateElement$2 = new WeakMap();
3041
+ const registryByStateElement$3 = new WeakMap();
2646
3042
  function getOrCreateEventToken(stateElement, name) {
2647
- let registry = registryByStateElement$2.get(stateElement);
3043
+ let registry = registryByStateElement$3.get(stateElement);
2648
3044
  if (typeof registry === "undefined") {
2649
3045
  registry = new Map();
2650
- registryByStateElement$2.set(stateElement, registry);
3046
+ registryByStateElement$3.set(stateElement, registry);
2651
3047
  }
2652
3048
  let token = registry.get(name);
2653
3049
  if (typeof token === "undefined") {
@@ -2657,7 +3053,7 @@ function getOrCreateEventToken(stateElement, name) {
2657
3053
  return token;
2658
3054
  }
2659
3055
  function clearEventTokenRegistry(stateElement) {
2660
- registryByStateElement$2.delete(stateElement);
3056
+ registryByStateElement$3.delete(stateElement);
2661
3057
  }
2662
3058
 
2663
3059
  /**
@@ -2695,7 +3091,7 @@ function getWcBindable$1(element) {
2695
3091
  return readBindableDeclaration(element);
2696
3092
  }
2697
3093
  function attachEventTokenHandler(binding) {
2698
- if (binding.propSegments[0] !== "eventToken") {
3094
+ if (binding.propSegments[0] !== EVENT_TOKEN_NAMESPACE) {
2699
3095
  return false;
2700
3096
  }
2701
3097
  const element = binding.node;
@@ -2722,16 +3118,16 @@ function attachEventTokenHandler(binding) {
2722
3118
  }
2723
3119
  const propDesc = bindable.knownProperties.get(propertyName);
2724
3120
  if (typeof propDesc === "undefined") {
2725
- raiseError(`Property "${propertyName}" is not declared in wcBindable.properties of <${element.tagName.toLowerCase()}>.`);
3121
+ raiseError(`Property "${propertyName}" is not declared in wcBindable.properties of <${element.tagName.toLowerCase()}>.${didYouMean(propertyName, bindable.knownProperties.keys())}`);
2726
3122
  }
2727
3123
  const eventName = propDesc.event;
2728
3124
  const tokenName = binding.statePathName;
2729
3125
  const stateName = binding.stateName;
2730
3126
  const modifiers = binding.propModifiers;
2731
3127
  const handler = (event) => {
2732
- if (modifiers.includes("prevent"))
3128
+ if (modifiers.includes(MODIFIER_PREVENT))
2733
3129
  event.preventDefault();
2734
- if (modifiers.includes("stop"))
3130
+ if (modifiers.includes(MODIFIER_STOP))
2735
3131
  event.stopPropagation();
2736
3132
  // state は発火時の live root から解決する(attach 時は detached の可能性があるため)。
2737
3133
  const rootNode = element.getRootNode();
@@ -2740,7 +3136,8 @@ function attachEventTokenHandler(binding) {
2740
3136
  raiseError(`State element with name "${stateName}" not found for eventToken handler.`);
2741
3137
  }
2742
3138
  if (!stateElement.eventTokenNames.has(tokenName)) {
2743
- raiseError(`eventToken "${tokenName}" is not declared in $eventTokens of state "${stateName}".`);
3139
+ // lint も同じケースを wcs/token-undeclared で検出する(三面同語彙)。
3140
+ raiseError(`[wcs/token-undeclared] eventToken "${tokenName}" is not declared in $eventTokens of state "${stateName}".${didYouMean(tokenName, stateElement.eventTokenNames)}${LINT_HINT}`);
2744
3141
  }
2745
3142
  const loopContext = getLoopContextByNode(element);
2746
3143
  stateElement.createStateAsync("writable", async (state) => {
@@ -2760,7 +3157,7 @@ function attachEventTokenHandler(binding) {
2760
3157
  return true;
2761
3158
  }
2762
3159
  function detachEventTokenHandler(binding) {
2763
- if (binding.propSegments[0] !== "eventToken") {
3160
+ if (binding.propSegments[0] !== EVENT_TOKEN_NAMESPACE) {
2764
3161
  return false;
2765
3162
  }
2766
3163
  const listener = listenerByBinding.get(binding);
@@ -2813,13 +3210,13 @@ const handlerByHandlerKey$2 = new Map();
2813
3210
  // binding を強参照しない台帳(handlerBindingRegistry.ts のリーク解説を参照)
2814
3211
  const bindingRegistry$2 = createHandlerBindingRegistry();
2815
3212
  function getHandlerKey$2(binding) {
2816
- const modifierKey = binding.propModifiers.filter(m => m === 'prevent' || m === 'stop').sort().join(',');
3213
+ const modifierKey = binding.propModifiers.filter(m => m === MODIFIER_PREVENT || m === MODIFIER_STOP).sort().join(',');
2817
3214
  return `${binding.stateName}::${binding.statePathName}::${modifierKey}`;
2818
3215
  }
2819
3216
  const stateEventHandlerFunction = (stateName, handlerName, modifiers, statePathInfo) => (event) => {
2820
- if (modifiers.includes('prevent'))
3217
+ if (modifiers.includes(MODIFIER_PREVENT))
2821
3218
  event.preventDefault();
2822
- if (modifiers.includes('stop'))
3219
+ if (modifiers.includes(MODIFIER_STOP))
2823
3220
  event.stopPropagation();
2824
3221
  const node = event.target;
2825
3222
  const rootNode = node.getRootNode();
@@ -2853,7 +3250,7 @@ const stateEventHandlerFunction = (stateName, handlerName, modifiers, statePathI
2853
3250
  });
2854
3251
  };
2855
3252
  function attachEventHandler(binding) {
2856
- if (!binding.propName.startsWith("on")) {
3253
+ if (!binding.propName.startsWith(EVENT_PROP_PREFIX)) {
2857
3254
  return false;
2858
3255
  }
2859
3256
  const key = getHandlerKey$2(binding);
@@ -2868,7 +3265,7 @@ function attachEventHandler(binding) {
2868
3265
  return true;
2869
3266
  }
2870
3267
  function detachEventHandler(binding) {
2871
- if (!binding.propName.startsWith("on")) {
3268
+ if (!binding.propName.startsWith(EVENT_PROP_PREFIX)) {
2872
3269
  return false;
2873
3270
  }
2874
3271
  const key = getHandlerKey$2(binding);
@@ -2897,8 +3294,8 @@ function getHandlerKey$1(binding, eventName) {
2897
3294
  function getEventName$1(binding) {
2898
3295
  let eventName = 'input';
2899
3296
  for (const modifier of binding.propModifiers) {
2900
- if (modifier.startsWith('on')) {
2901
- eventName = modifier.slice(2);
3297
+ if (modifier.startsWith(EVENT_PROP_PREFIX)) {
3298
+ eventName = modifier.slice(EVENT_PROP_PREFIX.length);
2902
3299
  }
2903
3300
  }
2904
3301
  return eventName;
@@ -2934,7 +3331,7 @@ const radioEventHandlerFunction = (stateName, statePathName, inFilters) => (even
2934
3331
  });
2935
3332
  };
2936
3333
  function attachRadioEventHandler(binding) {
2937
- if (binding.bindingType === "radio" && binding.propModifiers.indexOf('ro') === -1) {
3334
+ if (binding.bindingType === "radio" && binding.propModifiers.indexOf(MODIFIER_READONLY) === -1) {
2938
3335
  const eventName = getEventName$1(binding);
2939
3336
  const key = getHandlerKey$1(binding, eventName);
2940
3337
  let radioEventHandler = handlerByHandlerKey$1.get(key);
@@ -2949,7 +3346,7 @@ function attachRadioEventHandler(binding) {
2949
3346
  return false;
2950
3347
  }
2951
3348
  function detachRadioEventHandler(binding) {
2952
- if (binding.bindingType === "radio" && binding.propModifiers.indexOf('ro') === -1) {
3349
+ if (binding.bindingType === "radio" && binding.propModifiers.indexOf(MODIFIER_READONLY) === -1) {
2953
3350
  const eventName = getEventName$1(binding);
2954
3351
  const key = getHandlerKey$1(binding, eventName);
2955
3352
  const radioEventHandler = handlerByHandlerKey$1.get(key);
@@ -3188,10 +3585,10 @@ function getEventName(binding) {
3188
3585
  eventName = propDesc.event;
3189
3586
  }
3190
3587
  }
3191
- // 3.modifier
3588
+ // 3.modifier(`#onchange` 等 — `on` + イベント名の修飾子形。README「Modifiers」参照)
3192
3589
  for (const modifier of binding.propModifiers) {
3193
- if (modifier.startsWith('on')) {
3194
- eventName = modifier.slice(2);
3590
+ if (modifier.startsWith(EVENT_PROP_PREFIX)) {
3591
+ eventName = modifier.slice(EVENT_PROP_PREFIX.length);
3195
3592
  }
3196
3593
  }
3197
3594
  return eventName;
@@ -3360,7 +3757,7 @@ function attachTwowayEventHandler(binding) {
3360
3757
  return;
3361
3758
  }
3362
3759
  }
3363
- if (isPossibleTwoWay(binding.node, binding.propName) && binding.propModifiers.indexOf('ro') === -1) {
3760
+ if (isPossibleTwoWay(binding.node, binding.propName) && binding.propModifiers.indexOf(MODIFIER_READONLY) === -1) {
3364
3761
  const eventName = getEventName(binding);
3365
3762
  const valueGetter = getValueGetter(binding);
3366
3763
  const isOccurrence = isOccurrenceProperty(binding);
@@ -3386,7 +3783,7 @@ function detachTwowayEventHandler(binding) {
3386
3783
  return;
3387
3784
  }
3388
3785
  }
3389
- if (isPossibleTwoWay(binding.node, binding.propName) && binding.propModifiers.indexOf('ro') === -1) {
3786
+ if (isPossibleTwoWay(binding.node, binding.propName) && binding.propModifiers.indexOf(MODIFIER_READONLY) === -1) {
3390
3787
  const eventName = getEventName(binding);
3391
3788
  const valueGetter = getValueGetter(binding);
3392
3789
  const key = getHandlerKey(binding, eventName, valueGetter !== null, isOccurrenceProperty(binding));
@@ -3401,6 +3798,42 @@ function detachTwowayEventHandler(binding) {
3401
3798
  }
3402
3799
  }
3403
3800
 
3801
+ const stateElementByWebComponent = new WeakMap();
3802
+ function setStateElementByWebComponent(webComponent, stateName, stateElement) {
3803
+ let stateMap = stateElementByWebComponent.get(webComponent);
3804
+ if (!stateMap) {
3805
+ stateMap = new Map();
3806
+ stateElementByWebComponent.set(webComponent, stateMap);
3807
+ }
3808
+ stateMap.set(stateName, stateElement);
3809
+ }
3810
+ function getStateElementByWebComponent(webComponent, stateName) {
3811
+ const stateMap = stateElementByWebComponent.get(webComponent);
3812
+ if (!stateMap) {
3813
+ return null;
3814
+ }
3815
+ return stateMap.get(stateName) ?? null;
3816
+ }
3817
+ /**
3818
+ * コンポーネントが mapped されている「1 つ外のスコープ」の state 要素。
3819
+ * `buildPrimaryMappingRule` がプライマリ規則から記録する
3820
+ * (規則の outer 側が属する state 要素 = 値の正本を持つスコープそのもの)。
3821
+ *
3822
+ * 用途は Δ(base listIndex)の境界越え合成(§1.12)。`getLoopContextByNode` は
3823
+ * `parentNode` しか辿らず shadow 境界を越えないため、Δ を外へ引き継ぐには
3824
+ * 「1 つ外のスコープ」への明示的なリンクが要る。
3825
+ *
3826
+ * この台帳を MappingRule ではなくここに置くのは循環参照を避けるため
3827
+ * (baseListIndex → MappingRule → BindingSession → outerListPath → baseListIndex)。
3828
+ */
3829
+ const outerStateElementByWebComponent = new WeakMap();
3830
+ function setOuterStateElementByWebComponent(webComponent, stateElement) {
3831
+ outerStateElementByWebComponent.set(webComponent, stateElement);
3832
+ }
3833
+ function getOuterStateElementByWebComponent(webComponent) {
3834
+ return outerStateElementByWebComponent.get(webComponent) ?? null;
3835
+ }
3836
+
3404
3837
  /**
3405
3838
  * webComponent/baseListIndex.ts
3406
3839
  *
@@ -3423,19 +3856,49 @@ function detachTwowayEventHandler(binding) {
3423
3856
  * ホットパスに walk は載らない。
3424
3857
  */
3425
3858
  function getBaseListIndex(stateElement) {
3426
- if (stateElement == null || stateElement.hasMappedComponentState !== true) {
3427
- return null;
3428
- }
3429
- const component = stateElement.boundComponent;
3430
- if (component == null) {
3431
- return null;
3859
+ let current = stateElement;
3860
+ for (;;) {
3861
+ if (current == null || current.hasMappedComponentState !== true) {
3862
+ return null;
3863
+ }
3864
+ const component = current.boundComponent;
3865
+ if (component == null) {
3866
+ return null;
3867
+ }
3868
+ const listIndex = getLoopContextByNode(component)?.listIndex;
3869
+ if (listIndex != null) {
3870
+ return listIndex;
3871
+ }
3872
+ // このスコープには囲むループが無い。コンポーネントがさらに別の mapped な
3873
+ // コンポーネントの shadow の中にいるなら、Δ は外側スコープから引き継ぐ(§1.12)。
3874
+ //
3875
+ // `getLoopContextByNode` は `parentNode` しか辿らず、ShadowRoot の parentNode は
3876
+ // null なので shadow 境界で必ず止まる。境界 1 枚なら外側は素の文書スコープで
3877
+ // Δ=0 が正しいが、2 枚重なっていると中間スコープの Δ が丸ごと落ちて
3878
+ // 子の listIndex が正本スコープより浅い arity で作られる。
3879
+ //
3880
+ // 外側が mapped でない(=値の正本がそのスコープにある)なら、そこから先の
3881
+ // ループはこの子のリストとは無関係なので次の周回の先頭ガードで止まる。
3882
+ current = getOuterStateElementByWebComponent(component);
3432
3883
  }
3433
- return getLoopContextByNode(component)?.listIndex ?? null;
3434
3884
  }
3435
3885
  /** base の段数 Δ。base が無ければ 0。 */
3436
3886
  function getBaseDepth(stateElement) {
3437
3887
  return getBaseListIndex(stateElement)?.length ?? 0;
3438
3888
  }
3889
+ /**
3890
+ * そのスコープでそのパスに実際に使われる listIndex の arity。
3891
+ *
3892
+ * パス自身のワイルドカード段数に、そのスコープが外側のループの内側にいる分(Δ)を
3893
+ * 足したもの。`items.*` は子スコープから見れば 1 段でも、そのスコープが Δ=1 の位置に
3894
+ * あれば台帳の listIndex は arity 2 になる(§1.10)。
3895
+ *
3896
+ * **境界を跨ぐ照合はこの実 arity どうしで行うこと**(§1.12)。片側だけ Δ を足すと、
3897
+ * 境界が 2 枚以上あるときに中間スコープの Δ を二重計上して不一致になる。
3898
+ */
3899
+ function getScopeArity(stateElement, pathInfo) {
3900
+ return pathInfo.wildcardCount + getBaseDepth(stateElement);
3901
+ }
3439
3902
  /**
3440
3903
  * リストの行を生成するときの親 listIndex。
3441
3904
  *
@@ -3451,23 +3914,6 @@ function getListParentListIndex(stateElement, containerListIndex) {
3451
3914
  return containerListIndex ?? getBaseListIndex(stateElement);
3452
3915
  }
3453
3916
 
3454
- const stateElementByWebComponent = new WeakMap();
3455
- function setStateElementByWebComponent(webComponent, stateName, stateElement) {
3456
- let stateMap = stateElementByWebComponent.get(webComponent);
3457
- if (!stateMap) {
3458
- stateMap = new Map();
3459
- stateElementByWebComponent.set(webComponent, stateMap);
3460
- }
3461
- stateMap.set(stateName, stateElement);
3462
- }
3463
- function getStateElementByWebComponent(webComponent, stateName) {
3464
- const stateMap = stateElementByWebComponent.get(webComponent);
3465
- if (!stateMap) {
3466
- return null;
3467
- }
3468
- return stateMap.get(stateName) ?? null;
3469
- }
3470
-
3471
3917
  const innerMappingByElement = new WeakMap();
3472
3918
  const outerMappingByElement = new WeakMap();
3473
3919
  const primaryMappingRuleSetByElement = new WeakMap();
@@ -3503,6 +3949,10 @@ function buildPrimaryMappingRule(webComponent, stateName, bindings) {
3503
3949
  primaryBindingByMappingRule.set(mappingRule, binding);
3504
3950
  innerMappingRule.set(innerAbsPathInfo, outerAbsPathInfo);
3505
3951
  outerMappingRule.set(outerAbsPathInfo, innerAbsPathInfo);
3952
+ // 1 つ外のスコープへのリンク。Δ の境界越え合成(§1.12)が引く。
3953
+ // プライマリ規則はすべて同じホスト要素の data-wcs 由来なので、どの規則から
3954
+ // 採っても同じスコープを指す。
3955
+ setOuterStateElementByWebComponent(webComponent, outerAbsPathInfo.stateElement);
3506
3956
  }
3507
3957
  innerMappingByElement.set(webComponent, innerMappingRule);
3508
3958
  outerMappingByElement.set(webComponent, outerMappingRule);
@@ -3728,12 +4178,56 @@ function getOuterRowPathInfo(innerStateElement, innerPathInfo) {
3728
4178
  if (innerPathInfo.wildcardCount === 0) {
3729
4179
  return null;
3730
4180
  }
4181
+ return stepOuterRowPathInfo(innerStateElement, innerPathInfo);
4182
+ }
4183
+ /**
4184
+ * `getOuterRowPathInfo` の 2 段目以降。境界が 2 枚以上重なっている(コンポーネントの
4185
+ * shadow の中にさらに mapped な `bind-component` がある)場合、値の正本は 1 つ外では
4186
+ * なく**最も外のスコープ**にある。1 段目だけに相乗りしていると、中間スコープは
4187
+ * 素通しで自分の行バインディングを持たないため、正本スコープ起点の行フィールド
4188
+ * 書き込みを購読する者が誰もいなくなる(§1.11)。
4189
+ *
4190
+ * 1 段目が成立したときだけ呼ばれる(=mapped な行バインディング限定)ので、
4191
+ * 通常のリストはこの walk を一切踏まない。返り値は 2 段目以降が無ければ `null` で、
4192
+ * 圧倒的多数である深さ 1 の行では配列を確保しない。
4193
+ *
4194
+ * 各段で必ず外側の state 要素へ進む(`resolveOuterAbsolutePathInfo` は
4195
+ * `boundComponent` の属するスコープを返す=DOM 上の真の祖先)ので停止する。
4196
+ * `propagateListPathToOuterState` の外向き伝播と同じ論拠。
4197
+ */
4198
+ function getOuterRowPathInfosBeyond(firstOuterAbsPathInfo) {
4199
+ let rest = null;
4200
+ let stateElement = firstOuterAbsPathInfo.stateElement;
4201
+ let pathInfo = firstOuterAbsPathInfo.pathInfo;
4202
+ for (;;) {
4203
+ const outerAbsPathInfo = stepOuterRowPathInfo(stateElement, pathInfo);
4204
+ if (outerAbsPathInfo === null) {
4205
+ return rest;
4206
+ }
4207
+ (rest ??= []).push(outerAbsPathInfo);
4208
+ stateElement = outerAbsPathInfo.stateElement;
4209
+ pathInfo = outerAbsPathInfo.pathInfo;
4210
+ }
4211
+ }
4212
+ /**
4213
+ * 境界 1 枚分の外向き解決。成立条件の判定を含む。
4214
+ *
4215
+ * 判定は**両側の実 arity(`getScopeArity` = パスの段数 + そのスコープの Δ)が
4216
+ * 一致すること**。相乗り登録は子の listIndex をそのまま鍵に使うので、外側スコープが
4217
+ * その arity で台帳を引けなければ意味がない。
4218
+ *
4219
+ * 境界 1 枚なら外側は Δ=0 なので、これは従来の `Δ + innerW === outerW` と同値。
4220
+ * 2 枚以上あるときに外側の Δ を数えないと、中間スコープの Δ を二重計上して
4221
+ * 成立するはずの段を落とす(§1.12)。
4222
+ */
4223
+ function stepOuterRowPathInfo(innerStateElement, innerPathInfo) {
3731
4224
  const outerAbsPathInfo = resolveOuterAbsolutePathInfo(innerStateElement, innerPathInfo);
3732
4225
  if (outerAbsPathInfo === null || outerAbsPathInfo.stateElement === innerStateElement) {
3733
4226
  return null;
3734
4227
  }
3735
- const baseDepth = getBaseDepth(innerStateElement);
3736
- if (outerAbsPathInfo.pathInfo.wildcardCount !== innerPathInfo.wildcardCount + baseDepth) {
4228
+ const innerArity = getScopeArity(innerStateElement, innerPathInfo);
4229
+ const outerArity = getScopeArity(outerAbsPathInfo.stateElement, outerAbsPathInfo.pathInfo);
4230
+ if (innerArity !== outerArity) {
3737
4231
  return null;
3738
4232
  }
3739
4233
  return outerAbsPathInfo;
@@ -3877,7 +4371,7 @@ function readOption(binding, key) {
3877
4371
  continue;
3878
4372
  const modifierKey = modifier.slice(0, separator).trim();
3879
4373
  const value = modifier.slice(separator + 1).trim();
3880
- if (modifierKey !== "init" && modifierKey !== "sync") {
4374
+ if (modifierKey !== MODIFIER_KEY_INIT && modifierKey !== MODIFIER_KEY_SYNC) {
3881
4375
  raiseError(`Unknown binding modifier "${modifierKey}" in "${modifier}".`);
3882
4376
  }
3883
4377
  if (modifierKey !== key)
@@ -3924,8 +4418,8 @@ function resolveInitialSyncPolicy(binding) {
3924
4418
  }
3925
4419
  return STATE_CALL_POLICY;
3926
4420
  }
3927
- const explicitAuthority = parseAuthority(readOption(binding, "init"));
3928
- const syncOn = parseSyncOn(readOption(binding, "sync"));
4421
+ const explicitAuthority = parseAuthority(readOption(binding, MODIFIER_KEY_INIT));
4422
+ const syncOn = parseSyncOn(readOption(binding, MODIFIER_KEY_SYNC));
3929
4423
  if (binding.bindingType === "event") {
3930
4424
  if (explicitAuthority !== null && explicitAuthority !== "none") {
3931
4425
  raiseError("Event bindings only allow init=none.");
@@ -3937,7 +4431,7 @@ function resolveInitialSyncPolicy(binding) {
3937
4431
  // property authority 検証(未宣言なら raiseError)に掛けてはならない。値の初期同期を
3938
4432
  // 持たない配線なので、現行互換の "state" authority を返す(command token は従来通り
3939
4433
  // 初期 apply で配線される)。
3940
- if (binding.propSegments[0] === "command") {
4434
+ if (binding.propSegments[0] === COMMAND_NAMESPACE) {
3941
4435
  return statePolicy("state", syncOn);
3942
4436
  }
3943
4437
  if (binding.bindingType !== "prop") {
@@ -4552,6 +5046,7 @@ class BindingSession {
4552
5046
  patternPathInfo: null,
4553
5047
  patternListIndex: null,
4554
5048
  outerPatternPathInfo: null,
5049
+ outerPatternPathInfosRest: null,
4555
5050
  pendingDefinitions: 0,
4556
5051
  initialPolicy: slot.policy,
4557
5052
  resolvedAuthority: slot.authority,
@@ -4668,6 +5163,7 @@ class BindingSession {
4668
5163
  patternPathInfo: null,
4669
5164
  patternListIndex: null,
4670
5165
  outerPatternPathInfo: null,
5166
+ outerPatternPathInfosRest: null,
4671
5167
  pendingDefinitions: 0,
4672
5168
  initialPolicy: null,
4673
5169
  resolvedAuthority: null,
@@ -4708,7 +5204,7 @@ class BindingSession {
4708
5204
  record.eventAttached = true;
4709
5205
  return;
4710
5206
  }
4711
- if (binding.propSegments[0] === "eventToken") {
5207
+ if (binding.propSegments[0] === EVENT_TOKEN_NAMESPACE) {
4712
5208
  this.attachAfterDefinition(record, () => {
4713
5209
  if (attachEventTokenHandler(binding)) {
4714
5210
  addRecordTeardown(record, () => detachEventTokenHandler(binding));
@@ -4734,7 +5230,7 @@ class BindingSession {
4734
5230
  // isPossibleTwoWay の未定義 CE raiseError も踏まない)。
4735
5231
  if (config.enableDirectionalInitialSync
4736
5232
  && isPossibleTwoWay(binding.node, binding.propName)
4737
- && binding.propModifiers.indexOf("ro") === -1) {
5233
+ && binding.propModifiers.indexOf(MODIFIER_READONLY) === -1) {
4738
5234
  const removeObserver = addTwowayValueObserver(binding.node, binding.propName, (value) => {
4739
5235
  if (!this.isAlive(record, record.generation))
4740
5236
  return;
@@ -4884,6 +5380,17 @@ class BindingSession {
4884
5380
  if (outerPathInfo !== null) {
4885
5381
  addBindingByPattern(outerPathInfo, listIndex, binding);
4886
5382
  record.outerPatternPathInfo = outerPathInfo;
5383
+ // 境界が 2 枚以上重なっていると、値の正本は 1 つ外ではなく最も外のスコープに
5384
+ // ある。中間スコープは配列を素通しするだけで自分の行バインディングを持たない
5385
+ // ため、1 段目だけでは正本スコープ起点の行フィールド書き込みが誰にも届かない
5386
+ // (§1.11)。成立する段すべてに載せる。
5387
+ const restPathInfos = getOuterRowPathInfosBeyond(outerPathInfo);
5388
+ if (restPathInfos !== null) {
5389
+ for (let i = 0; i < restPathInfos.length; i++) {
5390
+ addBindingByPattern(restPathInfos[i], listIndex, binding);
5391
+ }
5392
+ record.outerPatternPathInfosRest = restPathInfos;
5393
+ }
4887
5394
  }
4888
5395
  }
4889
5396
  else {
@@ -4951,6 +5458,19 @@ class BindingSession {
4951
5458
  }
4952
5459
  record.outerPatternPathInfo = null;
4953
5460
  }
5461
+ // 3 段目以降(§1.11)。各段も互いに独立した資源なので 1 つずつ守る
5462
+ if (record.outerPatternPathInfosRest !== null) {
5463
+ const restPathInfos = record.outerPatternPathInfosRest;
5464
+ for (let i = 0; i < restPathInfos.length; i++) {
5465
+ try {
5466
+ removeBindingByPattern(restPathInfos[i], record.patternListIndex, binding);
5467
+ }
5468
+ catch {
5469
+ // Cleanup is best-effort.
5470
+ }
5471
+ }
5472
+ record.outerPatternPathInfosRest = null;
5473
+ }
4954
5474
  try {
4955
5475
  removeBindingByPattern(record.patternPathInfo, record.patternListIndex, binding);
4956
5476
  record.patternPathInfo = null;
@@ -5129,7 +5649,8 @@ function applyChangeToCommand(binding, _context, newValue) {
5129
5649
  raiseError(`command binding requires a wc-bindable custom element. <${element.tagName.toLowerCase()}> is not wc-bindable.`);
5130
5650
  }
5131
5651
  if (!bindable.declaredCommands.has(methodName)) {
5132
- raiseError(`Command "${methodName}" is not declared in wcBindable.commands of <${element.tagName.toLowerCase()}>.`);
5652
+ // eventTokenHandler property 検証と対双の did-you-mean(設計 §3)。
5653
+ raiseError(`Command "${methodName}" is not declared in wcBindable.commands of <${element.tagName.toLowerCase()}>.${didYouMean(methodName, bindable.declaredCommands.keys())}`);
5133
5654
  }
5134
5655
  // ここまで来たら旧解除して新 subscribe に切り替える。
5135
5656
  if (existing) {
@@ -5789,7 +6310,7 @@ function compileRowPlan(fragmentInfo) {
5789
6310
  // command.<name>(prop 扱い)と eventToken.<prop>(event 扱い)は token 配線の
5790
6311
  // teardown / attach 分岐が要るため不適格
5791
6312
  const namespace = template.propSegments[0];
5792
- if (namespace === "command" || namespace === "eventToken") {
6313
+ if (namespace === COMMAND_NAMESPACE || namespace === EVENT_TOKEN_NAMESPACE) {
5793
6314
  return null;
5794
6315
  }
5795
6316
  if (bindingType === "text") {
@@ -6883,12 +7404,15 @@ function scheduleDeferredApply(binding, tagName) {
6883
7404
  }, reject);
6884
7405
  }
6885
7406
 
6886
- const applyChangeByFirstSegment = {
6887
- "class": applyChangeToClass,
6888
- "attr": applyChangeToAttribute,
6889
- "style": applyChangeToStyle,
6890
- "command": applyChangeToCommand,
6891
- };
7407
+ // キーは define.ts の namespace 語彙定数(manifest.syntax.bindingTypes.propNamespaces と
7408
+ // 同一の正本)。集合の一致は __tests__/manifest.test.ts の drift テストが強制するため
7409
+ // export する(manifest エントリは DOM 非依存でこのファイルを import できない)。
7410
+ const applyChangeByFirstSegment = Object.freeze({
7411
+ [CLASS_NAMESPACE]: applyChangeToClass,
7412
+ [ATTR_NAMESPACE]: applyChangeToAttribute,
7413
+ [STYLE_NAMESPACE]: applyChangeToStyle,
7414
+ [COMMAND_NAMESPACE]: applyChangeToCommand,
7415
+ });
6892
7416
  const applyChangeByBindingType = {
6893
7417
  "text": applyChangeToText,
6894
7418
  "for": applyChangeToFor,
@@ -7453,9 +7977,17 @@ function _getFragmentInfo(rootNode, fragment, parseBindingTextResult, forPath) {
7453
7977
  }
7454
7978
  function collectStructuralFragments(rootNode, walkRoot, forPath) {
7455
7979
  const elseKeyword = config.commentElsePrefix;
7980
+ // Light DOM の mapped コンポーネントの内側は、その子スコープが自分で処理する(§1.13)。
7981
+ // fragment info は rootNode + state 名で登録されるため、ホストのパスでここを拾うと
7982
+ // コンポーネント側の state がまだ名前登録を済ませておらず解決に失敗する。
7983
+ // コンポーネント要素自身は template ではないので、REJECT でサブツリーごと落として問題ない。
7984
+ const nestedComponents = findNestedLightDomComponents(walkRoot);
7456
7985
  const walker = document.createTreeWalker(walkRoot, NodeFilter.SHOW_ELEMENT, {
7457
7986
  acceptNode(node) {
7458
7987
  const element = node;
7988
+ if (nestedComponents.length > 0 && nestedComponents.indexOf(element) !== -1) {
7989
+ return NodeFilter.FILTER_REJECT;
7990
+ }
7459
7991
  if (element.tagName.toLowerCase() === 'template') {
7460
7992
  const bindText = element.getAttribute(config.bindAttributeName) || '';
7461
7993
  if (bindText.length > 0) {
@@ -7576,6 +8108,13 @@ async function waitForStateInitialize(root) {
7576
8108
  const promises = [];
7577
8109
  await customElements.whenDefined(config.tagNames.state);
7578
8110
  for (const element of elements) {
8111
+ // Light DOM の mapped コンポーネントの state は待たない。それはこの root の
8112
+ // バインディングが張られてからでないと初期化できず(自分を束ねるホスト binding を
8113
+ // 待つ)、ここで待つと循環する(§1.13)。Shadow DOM 形では別 rootNode にいるので
8114
+ // そもそもこの集合に現れず、plain 形は循環しないので従来どおり待つ。
8115
+ if (isLightDomMappedStateElement(element)) {
8116
+ continue;
8117
+ }
7579
8118
  const stateElement = element;
7580
8119
  promises.push(stateElement.initializePromise);
7581
8120
  }
@@ -7606,7 +8145,7 @@ async function buildBindings(root) {
7606
8145
  }
7607
8146
  }
7608
8147
 
7609
- var version = "1.26.0";
8148
+ var version = "1.28.0";
7610
8149
  var pkg = {
7611
8150
  version: version};
7612
8151
 
@@ -8441,27 +8980,89 @@ function setStateElementByName(rootNode, name, element) {
8441
8980
  }
8442
8981
  }
8443
8982
 
8444
- const updateBatchListeners = new Set();
8983
+ /**
8984
+ * watch/chainDepth.ts
8985
+ *
8986
+ * `$watch` ハンドラ起点の書き込み連鎖の深さを数える台帳
8987
+ * (docs/state-watch-hook-design.md §7-2)。
8988
+ *
8989
+ * watch ハンドラ内の書き込みは新しい microtask バッチを作るため、伝播 context の
8990
+ * hop 上限(MAX_PROPAGATION_HOPS)のガードが効かない。かつ書き込み先が動的なので、
8991
+ * `$streams` のような「宣言時の自己依存検出」も使えない。よって実行時に数える。
8992
+ *
8993
+ * updater(enqueue 側)と watchRuntime(発火側)の両方から参照されるため、
8994
+ * **依存ゼロの葉モジュール**にして循環 import を避ける(devtools/sink.ts と同じ方針)。
8995
+ *
8996
+ * 数え方: ハンドラ実行中に enqueue が起きたときだけ「次のバッチはこの連鎖の続き」と
8997
+ * マークする。ハンドラが何も書かなければ次のバッチは深さ 0 に戻るので、利用者操作が
8998
+ * 何度続いても深さは伸びない。
8999
+ */
9000
+ /** ハンドラ実行中に立つ「今の連鎖の深さ + 1」。0 なら watch 起点ではない */
9001
+ let firingDepth = 0;
9002
+ /** 次に drain されるバッチの深さ */
9003
+ let pendingDepth = 0;
9004
+ /** watch の発火フェーズ開始(watchRuntime 専用) */
9005
+ function beginWatchFiring(depth) {
9006
+ firingDepth = depth + 1;
9007
+ }
9008
+ /** watch の発火フェーズ終了(watchRuntime 専用。必ず finally で呼ぶ) */
9009
+ function endWatchFiring() {
9010
+ firingDepth = 0;
9011
+ }
9012
+ /**
9013
+ * 書き込みの enqueue を記録する(updater 専用)。
9014
+ * ハンドラ実行中でなければ何もしない = 通常の書き込みに深さは付かない。
9015
+ */
9016
+ function noteEnqueueForWatchChain() {
9017
+ if (firingDepth > pendingDepth) {
9018
+ pendingDepth = firingDepth;
9019
+ }
9020
+ }
9021
+ /** 次バッチの深さを消費する(watchRuntime 専用。読んだらリセット) */
9022
+ function consumeWatchChainDepth() {
9023
+ const depth = pendingDepth;
9024
+ pendingDepth = 0;
9025
+ return depth;
9026
+ }
9027
+
9028
+ const updateBatchListeners = [];
8445
9029
  /**
8446
9030
  * drain 終了リスナーを登録する。
9031
+ *
9032
+ * `priority` の昇順に呼ばれる(同値は登録順)。機構間の実行順序
9033
+ * (`$watch` → `$streams` restart、docs/state-watch-hook-design.md §3-2 層 1)は
9034
+ * この優先度で固定する — import 順に順序を持たせると、無関係な import 整理で
9035
+ * 静かに壊れるため。定数は define.ts の `*_LISTENER_PRIORITY` を使うこと。
8447
9036
  */
8448
- function registerUpdateBatchListener(listener) {
8449
- updateBatchListeners.add(listener);
9037
+ function registerUpdateBatchListener(listener, priority = 0) {
9038
+ // 挿入ソート: 同値優先度の中では登録順を保つ(find は最初の「より大きい」要素を指す)
9039
+ const index = updateBatchListeners.findIndex((registered) => registered.priority > priority);
9040
+ const entry = { listener, priority };
9041
+ if (index === -1) {
9042
+ updateBatchListeners.push(entry);
9043
+ }
9044
+ else {
9045
+ updateBatchListeners.splice(index, 0, entry);
9046
+ }
8450
9047
  }
8451
9048
  /**
8452
9049
  * drain 終了リスナーを解除する(テスト間の分離用)。
8453
9050
  */
8454
9051
  function unregisterUpdateBatchListener(listener) {
8455
- updateBatchListeners.delete(listener);
9052
+ const index = updateBatchListeners.findIndex((registered) => registered.listener === listener);
9053
+ if (index !== -1) {
9054
+ updateBatchListeners.splice(index, 1);
9055
+ }
8456
9056
  }
8457
9057
  /**
8458
- * 全リスナーに drain のバッチを通知する。
9058
+ * 全リスナーに drain のバッチを優先度順で通知する。
8459
9059
  * リスナーの throw は握りつぶさない(内部バグの隠蔽防止)。
8460
- * stream 側リスナーが entry ごとに自前で try/catch する契約(設計書 §3-2)。
9060
+ * stream / watch 側リスナーが entry ごとに自前で try/catch する契約(設計書 §3-2)。
8461
9061
  */
8462
9062
  function notifyUpdateBatchListeners(batch) {
8463
- for (const listener of updateBatchListeners) {
8464
- listener(batch);
9063
+ // 反復中の register / unregister(ハンドラ内の切断・再 set)に耐えるためコピーする
9064
+ for (const registered of updateBatchListeners.slice()) {
9065
+ registered.listener(batch);
8465
9066
  }
8466
9067
  }
8467
9068
  class Updater {
@@ -8469,6 +9070,9 @@ class Updater {
8469
9070
  constructor() {
8470
9071
  }
8471
9072
  enqueueAbsoluteAddress(absoluteAddress, context = null) {
9073
+ // `$watch` ハンドラ実行中の書き込みだけを連鎖としてマークする(watch/chainDepth.ts)。
9074
+ // ハンドラ実行中でなければ即 return する葉モジュール呼び出し 1 個のコスト。
9075
+ noteEnqueueForWatchChain();
8472
9076
  const requireStartProcess = this._queueUpdateRecords.length === 0;
8473
9077
  this._queueUpdateRecords.push({ absoluteAddress, context });
8474
9078
  if (requireStartProcess) {
@@ -8695,7 +9299,9 @@ function setSink(sink) {
8695
9299
  setDevtoolsSink(sink);
8696
9300
  const isActive = sink !== null;
8697
9301
  if (isActive && !wasActive) {
8698
- registerUpdateBatchListener(onUpdateBatch);
9302
+ // `$watch` / `$streams` restart より先に流す(protocol §4.3)。優先度を省略しても
9303
+ // 既定 0 で結果は同じだが、それは偶然なので定数で意図を固定する。
9304
+ registerUpdateBatchListener(onUpdateBatch, DEVTOOLS_LISTENER_PRIORITY);
8699
9305
  }
8700
9306
  else if (!isActive && wasActive) {
8701
9307
  unregisterUpdateBatchListener(onUpdateBatch);
@@ -9012,12 +9618,12 @@ function processCommandTokensDeclaration(state) {
9012
9618
  return names;
9013
9619
  }
9014
9620
 
9015
- const registryByStateElement$1 = new WeakMap();
9621
+ const registryByStateElement$2 = new WeakMap();
9016
9622
  function getOrCreateCommandToken(stateElement, name) {
9017
- let registry = registryByStateElement$1.get(stateElement);
9623
+ let registry = registryByStateElement$2.get(stateElement);
9018
9624
  if (typeof registry === "undefined") {
9019
9625
  registry = new Map();
9020
- registryByStateElement$1.set(stateElement, registry);
9626
+ registryByStateElement$2.set(stateElement, registry);
9021
9627
  }
9022
9628
  let token = registry.get(name);
9023
9629
  if (typeof token === "undefined") {
@@ -9027,7 +9633,7 @@ function getOrCreateCommandToken(stateElement, name) {
9027
9633
  return token;
9028
9634
  }
9029
9635
  function clearCommandTokenRegistry(stateElement) {
9030
- registryByStateElement$1.delete(stateElement);
9636
+ registryByStateElement$2.delete(stateElement);
9031
9637
  }
9032
9638
 
9033
9639
  /**
@@ -9139,7 +9745,7 @@ function processOnDeclaration(stateElement, state, eventTokenNames) {
9139
9745
  }
9140
9746
  for (const [name, handler] of Object.entries(declared)) {
9141
9747
  if (!eventTokenNames.has(name)) {
9142
- raiseError(`${STATE_ON_NAME} entry "${name}" is not declared in $eventTokens.`);
9748
+ raiseError(`${STATE_ON_NAME} entry "${name}" is not declared in $eventTokens.${didYouMean(name, eventTokenNames)}`);
9143
9749
  }
9144
9750
  if (typeof handler !== "function") {
9145
9751
  raiseError(`${STATE_ON_NAME} entry "${name}" must be a function.`);
@@ -9262,24 +9868,24 @@ function invalidateLastNotified(stateElement, name) {
9262
9868
  * 設計書 §3-2 の「未接続(disconnect 済み)の stateElement の entry は restart
9263
9869
  * しない」はこの不変条件で担保される。
9264
9870
  */
9265
- const activeStateElements = new Set();
9871
+ const activeStateElements$1 = new Set();
9266
9872
  /**
9267
9873
  * 起動中 stateElement として登録する(startStreams 専用。不変条件はモジュールヘッダ参照)。
9268
9874
  */
9269
9875
  function addActiveStateElement(stateElement) {
9270
- activeStateElements.add(stateElement);
9876
+ activeStateElements$1.add(stateElement);
9271
9877
  }
9272
9878
  /**
9273
9879
  * 起動中 stateElement から外す(abortAllStreams / clearStreamRegistry 専用)。
9274
9880
  */
9275
9881
  function deleteActiveStateElement(stateElement) {
9276
- activeStateElements.delete(stateElement);
9882
+ activeStateElements$1.delete(stateElement);
9277
9883
  }
9278
9884
  /**
9279
9885
  * 起動中 stateElement を列挙する(drain リスナーの交差判定用)。
9280
9886
  */
9281
9887
  function getActiveStateElements() {
9282
- return activeStateElements;
9888
+ return activeStateElements$1;
9283
9889
  }
9284
9890
 
9285
9891
  /**
@@ -9292,18 +9898,18 @@ function getActiveStateElements() {
9292
9898
  * - disconnect 時は abortAllStreams(abort のみ・registry 保持)、
9293
9899
  * `_state` 再 set 時のみ clearStreamRegistry(abort + 全削除)。
9294
9900
  */
9295
- const registryByStateElement = new WeakMap();
9901
+ const registryByStateElement$1 = new WeakMap();
9296
9902
  /**
9297
9903
  * stream entry 群を置換登録する(`_state` セッターからの再構築で丸ごと差し替える)。
9298
9904
  */
9299
9905
  function setStreamEntries(stateElement, entries) {
9300
- registryByStateElement.set(stateElement, entries);
9906
+ registryByStateElement$1.set(stateElement, entries);
9301
9907
  }
9302
9908
  /**
9303
9909
  * 登録済みの stream entry 群を返す。未登録なら空 Map を返す(registry への登録はしない)。
9304
9910
  */
9305
9911
  function getStreamEntries(stateElement) {
9306
- return registryByStateElement.get(stateElement) ?? new Map();
9912
+ return registryByStateElement$1.get(stateElement) ?? new Map();
9307
9913
  }
9308
9914
  /**
9309
9915
  * 全 stream を abort して idle に戻す(設計書 §5-1)。registry は保持する。
@@ -9323,7 +9929,7 @@ function abortAllStreams(stateElement) {
9323
9929
  // 設計書 §3-2。add 側は startStreams — stream/activeStateElements.ts の
9324
9930
  // リーク防止不変条件を参照)。registry の有無に関わらず必ず外す。
9325
9931
  deleteActiveStateElement(stateElement);
9326
- const entries = registryByStateElement.get(stateElement);
9932
+ const entries = registryByStateElement$1.get(stateElement);
9327
9933
  if (typeof entries === "undefined") {
9328
9934
  return;
9329
9935
  }
@@ -9343,7 +9949,7 @@ function clearStreamRegistry(stateElement) {
9343
9949
  // abortAllStreams が既に delete 済みだが、「clear = 全削除でも必ず restart 対象から
9344
9950
  // 外れる」不変条件を将来の abortAllStreams の変更から独立に保証するため明示的に呼ぶ。
9345
9951
  deleteActiveStateElement(stateElement);
9346
- registryByStateElement.delete(stateElement);
9952
+ registryByStateElement$1.delete(stateElement);
9347
9953
  }
9348
9954
 
9349
9955
  /**
@@ -10069,7 +10675,503 @@ function restartStreamsOnUpdateBatch(batch) {
10069
10675
  }
10070
10676
  }
10071
10677
  }
10072
- registerUpdateBatchListener(restartStreamsOnUpdateBatch);
10678
+ // 優先度で `$watch` の後に固定する(設計書 §3-2 層 1)。import 順には依存しない。
10679
+ registerUpdateBatchListener(restartStreamsOnUpdateBatch, STREAM_LISTENER_PRIORITY);
10680
+
10681
+ /**
10682
+ * watch/watchRegistry.ts
10683
+ *
10684
+ * `$watch` の registry と、drain リスナーの走査元になる「発火対象の stateElement 集合」
10685
+ * (docs/state-watch-hook-design.md §9)。
10686
+ *
10687
+ * `$streams` は registry(delete 側)と runtime(add 側)が相互に依存するため
10688
+ * active 集合を stream/activeStateElements.ts へ切り出しているが、`$watch` は
10689
+ * **add が State のライフサイクル側、delete が registry 側**で、runtime は読むだけの
10690
+ * 一方向依存になる。よって循環せず、1 モジュールにまとめられる。
10691
+ *
10692
+ * リーク防止の不変条件(strong Set が切断済み要素の GC を妨げないための連動):
10693
+ * - add は `startWatch`(`State.connectedCallback` の $connectedCallback 完了後、および
10694
+ * 接続中の `_state` 再 set)だけが行い、**宣言が 1 つも無い stateElement は入れない**。
10695
+ * - delete は `deactivateWatch`(disconnectedCallback)/`clearWatchRegistry`(`_state`
10696
+ * 再 set)だけが行う。
10697
+ * どちらの経路も必ずここを通るため「Set に居る = 接続中かつ宣言済み」が保たれる。
10698
+ * この「宣言済み」の側が崩れると、`$watch` 未使用アプリの drain にも収集ループが乗る
10699
+ * (ゼロコスト契約、docs/state-watch-hook-design.md §10)。
10700
+ */
10701
+ const registryByStateElement = new WeakMap();
10702
+ const activeStateElements = new Set();
10703
+ /**
10704
+ * 未登録時に返す共有の空 Map。
10705
+ *
10706
+ * ここで毎回 `new Map()` すると、drain の収集ループが「バッチのアドレス 1 個につき
10707
+ * Map を 1 個」アロケートすることになる(発火対象だが宣言を持たない stateElement を
10708
+ * 通る経路)。読み出ししかしない返り値なので 1 個を使い回す。
10709
+ */
10710
+ const EMPTY_ENTRIES = new Map();
10711
+ /**
10712
+ * watch entry 群を置換登録する(`_state` セッターからの再構築で丸ごと差し替える)。
10713
+ */
10714
+ function setWatchEntries(stateElement, entries) {
10715
+ registryByStateElement.set(stateElement, entries);
10716
+ }
10717
+ /**
10718
+ * 登録済みの watch entry 群を返す。未登録なら共有の空 Map を返す
10719
+ * (registry への登録はしない。返り値は読み出し専用)。
10720
+ */
10721
+ function getWatchEntries(stateElement) {
10722
+ return registryByStateElement.get(stateElement) ?? EMPTY_ENTRIES;
10723
+ }
10724
+ /**
10725
+ * 発火対象として登録する(`startWatch` 専用。不変条件はモジュールヘッダ参照)。
10726
+ */
10727
+ function addActiveWatchStateElement(stateElement) {
10728
+ activeStateElements.add(stateElement);
10729
+ }
10730
+ /**
10731
+ * 発火対象を列挙する(drain リスナーの early return 判定用)。
10732
+ */
10733
+ function getActiveWatchStateElements() {
10734
+ return activeStateElements;
10735
+ }
10736
+ /**
10737
+ * 発火対象から外す(切断時)。**registry は保持する。**
10738
+ *
10739
+ * `$streams` の abortAllStreams と同じ二段構えで、切断は「発火しなくなる」だけにする。
10740
+ * registry まで捨てると、再接続(connectedCallback → startWatch)で宣言を作り直す経路が
10741
+ * 無い(`_state` セッターは初回ロード時にしか走らない)ため、watch が二度と発火しない。
10742
+ */
10743
+ function deactivateWatch(stateElement) {
10744
+ activeStateElements.delete(stateElement);
10745
+ }
10746
+ /**
10747
+ * registry から削除し、発火対象からも外す(`_state` 再 set 時の再配線用)。
10748
+ */
10749
+ function clearWatchRegistry(stateElement) {
10750
+ activeStateElements.delete(stateElement);
10751
+ registryByStateElement.delete(stateElement);
10752
+ }
10753
+
10754
+ /**
10755
+ * watch/processWatchDeclaration.ts
10756
+ *
10757
+ * `$watch: { "<path>": (cur, prev, ...indexes) => void }` 宣言マップを解析し、
10758
+ * IWatchEntry を構築して watchRegistry に一括登録する
10759
+ * (docs/state-watch-hook-design.md §2-2 / §8)。
10760
+ *
10761
+ * `$streams` の processStreamsDeclaration と対称だが、**キーが宣言名ではなくパス**である
10762
+ * ぶん検証が異なる(`.` / `*` を許可し、代わりにパスとしての妥当性を見る)。
10763
+ *
10764
+ * 依存グラフ登録(§8)がこの関数の要点:
10765
+ * `setPathInfo` は BindingSession(= DOM バインディング登録)からしか呼ばれないため、
10766
+ * 静的依存グラフに載るのは「バインドされたパス」だけである。watch を宣言しただけでは
10767
+ * walkDependency がそのパスを知らず、`items` への代入で `items.*.price` がバッチに載らない
10768
+ * = ハンドラが黙って一度も発火しない。宣言時に自分で登録することでこれを塞ぐ。
10769
+ *
10770
+ * 呼び出しは stateElement の `_pathSet` クリア後・getterPaths 確定後であること
10771
+ * (State の `_state` セッターが順序を保証する)。
10772
+ */
10773
+ /**
10774
+ * `$watch` 宣言を registry へ反映し、監視対象パスの集合を返す。
10775
+ *
10776
+ * 宣言が無い(または空)なら **null** を返す。呼び出し側(State)はこれを
10777
+ * `watchPaths` に保持し、setByAddress のホットパスは `!== null` の分岐 1 個で
10778
+ * 抜けられる(ゼロコスト契約、§10)。
10779
+ */
10780
+ function processWatchDeclaration(stateElement, state) {
10781
+ const declared = state[STATE_WATCH_NAME];
10782
+ if (typeof declared === "undefined") {
10783
+ return null;
10784
+ }
10785
+ if (typeof declared !== "object" || declared === null) {
10786
+ // 非オブジェクト形は lint 側では候補ゼロ扱いで検出されないため LINT_HINT なし
10787
+ // (以下、lint が実際に検出する shape(非関数・$ 始まり・@ 越境・空セグメント)にだけ付ける)。
10788
+ raiseError(`[wcs/watch-declaration-invalid] ${STATE_WATCH_NAME} must be an object mapping state paths to handler functions.`);
10789
+ }
10790
+ const entries = new Map();
10791
+ const paths = new Set();
10792
+ let order = 0;
10793
+ for (const [path, handler] of Object.entries(declared)) {
10794
+ if (typeof handler !== "function") {
10795
+ raiseError(`[wcs/watch-declaration-invalid] ${STATE_WATCH_NAME} entry "${path}" must be a function.${LINT_HINT}`);
10796
+ }
10797
+ if (path.length === 0) {
10798
+ raiseError(`[wcs/watch-declaration-invalid] ${STATE_WATCH_NAME} entry name must be a non-empty state path.`);
10799
+ }
10800
+ if (path.startsWith("$")) {
10801
+ raiseError(`[wcs/watch-declaration-invalid] ${STATE_WATCH_NAME} entry "${path}" must not start with "$" (reserved namespace).${LINT_HINT}`);
10802
+ }
10803
+ // 越境 watch は不採用(設計 D8)。他 state のアドレスは発火対象にしないため、
10804
+ // `@stateName` 付きのパスは受け取った時点で落とす(黙って発火しないより良い)。
10805
+ if (path.includes(STATE_NAME_SEPARATOR)) {
10806
+ raiseError(`[wcs/watch-declaration-invalid] ${STATE_WATCH_NAME} entry "${path}" must not target another state ("${STATE_NAME_SEPARATOR}" is not allowed); watch only paths of its own state.${LINT_HINT}`);
10807
+ }
10808
+ // Object.prototype の継承名は `path in state` 系の判定を汚すため一律拒否する
10809
+ // (processStreamsDeclaration と同じ防衛線)。
10810
+ if (path in Object.prototype) {
10811
+ raiseError(`[wcs/watch-declaration-invalid] ${STATE_WATCH_NAME} entry "${path}" must not be a property name inherited from Object.prototype (e.g. "__proto__", "constructor").`);
10812
+ }
10813
+ const pathInfo = getPathInfo(path);
10814
+ // 空セグメント("a..b" / 先頭・末尾の ".")は getPathInfo が黙って受理してしまうため、
10815
+ // ここで落とす。放置すると解決不能なアドレスを依存グラフへ登録することになる。
10816
+ for (const segment of pathInfo.segments) {
10817
+ if (segment.length === 0) {
10818
+ raiseError(`[wcs/watch-declaration-invalid] ${STATE_WATCH_NAME} entry "${path}" has an empty path segment.${LINT_HINT}`);
10819
+ }
10820
+ }
10821
+ if (pathInfo.wildcardCount > MAX_WILDCARD_DEPTH) {
10822
+ raiseError(`[wcs/watch-declaration-invalid] ${STATE_WATCH_NAME} entry "${path}" exceeds the maximum wildcard depth (${MAX_WILDCARD_DEPTH}).`);
10823
+ }
10824
+ entries.set(path, {
10825
+ path,
10826
+ pathInfo,
10827
+ handler: handler,
10828
+ order: order++,
10829
+ });
10830
+ paths.add(path);
10831
+ // 依存グラフ登録(§8)。"for" 以外の bindingType は親 → 子の staticDependency
10832
+ // チェーンを生やすだけで listPaths / elementPaths を触らない(State.setPathInfo 参照)。
10833
+ stateElement.setPathInfo(path, "prop");
10834
+ }
10835
+ setWatchEntries(stateElement, entries);
10836
+ return paths.size > 0 ? paths : null;
10837
+ }
10838
+
10839
+ /**
10840
+ * watch/computedSnapshots.ts
10841
+ *
10842
+ * watch 対象の computed(getter)の「前回評価値」台帳
10843
+ * (docs/state-watch-hook-design.md §5)。
10844
+ *
10845
+ * getter は `setByAddress` を通らないので、スカラ書き込み用の旧値台帳
10846
+ * (watch/prevValues.ts)には載らない。`prev` を渡すには前回の評価値を
10847
+ * **バッチを跨いで**保持する必要があり、こちらは drain ごとにクリアしない。
10848
+ *
10849
+ * 寿命は stateElement 単位(WeakMap)。`_state` 再 set では宣言ごと作り直すため
10850
+ * 破棄する。切断では破棄しない —— 再接続時の初回評価が上書きするので、
10851
+ * 残っていても害がなく、registry を保持する扱いとも揃う。
10852
+ */
10853
+ const snapshotsByStateElement = new WeakMap();
10854
+ function getComputedSnapshot(stateElement, absAddress) {
10855
+ return snapshotsByStateElement.get(stateElement)?.get(absAddress);
10856
+ }
10857
+ function setComputedSnapshot(stateElement, absAddress, value) {
10858
+ let snapshots = snapshotsByStateElement.get(stateElement);
10859
+ if (typeof snapshots === "undefined") {
10860
+ snapshots = new Map();
10861
+ snapshotsByStateElement.set(stateElement, snapshots);
10862
+ }
10863
+ snapshots.set(absAddress, value);
10864
+ }
10865
+ /** `_state` 再 set で宣言ごと作り直すときに破棄する */
10866
+ function clearComputedSnapshots(stateElement) {
10867
+ snapshotsByStateElement.delete(stateElement);
10868
+ }
10869
+
10870
+ /**
10871
+ * watch/prevValues.ts
10872
+ *
10873
+ * `$watch` ハンドラへ渡す `prev`(バッチ開始時点の値)の台帳
10874
+ * (docs/state-watch-hook-design.md §4-1)。
10875
+ *
10876
+ * 記録するのは **watch 宣言済みパスへの書き込みだけ**、かつ **バッチ内で最初の 1 回だけ**
10877
+ * (first-write-wins)。したがって `prev` は「そのバッチが始まる前の値」、`cur` は
10878
+ * drain 時点の確定値になる。同一バッチ内の中間値は観測できない(§3-4)。
10879
+ *
10880
+ * 値の出どころは same-value guard が既に読んでいる旧値であり、watch のために
10881
+ * 追加の getByAddress は行わない(§10)。その帰結として:
10882
+ * - 参照型(object / array)は guard が素通しするので `prev` は undefined
10883
+ * - `config.sameValueGuard = false` でも undefined
10884
+ * - `$postUpdate` / stream の status 通知は setByAddress を通らないので undefined
10885
+ *
10886
+ * 台帳は drain 終端(watchRuntime)でクリアされる。drain の外で書き込まれた分が
10887
+ * 次のバッチへ持ち越されることはない。
10888
+ */
10889
+ const prevValueByAbsoluteStateAddress = new Map();
10890
+ /**
10891
+ * バッチ内で最初の書き込みのときだけ旧値を記録する(first-write-wins)。
10892
+ */
10893
+ function recordPrevValue(absAddress, oldValue) {
10894
+ if (prevValueByAbsoluteStateAddress.has(absAddress)) {
10895
+ return;
10896
+ }
10897
+ prevValueByAbsoluteStateAddress.set(absAddress, oldValue);
10898
+ }
10899
+ /**
10900
+ * 記録済みの旧値を返す。記録が無ければ undefined(§4-1 の「prev を保証しない」経路)。
10901
+ */
10902
+ function getPrevValue(absAddress) {
10903
+ return prevValueByAbsoluteStateAddress.get(absAddress);
10904
+ }
10905
+ /**
10906
+ * 台帳をクリアする(drain 終端で必ず呼ぶ)。
10907
+ */
10908
+ function clearPrevValues() {
10909
+ prevValueByAbsoluteStateAddress.clear();
10910
+ }
10911
+
10912
+ /**
10913
+ * watch/watchRuntime.ts
10914
+ *
10915
+ * `$watch` の発火(docs/state-watch-hook-design.md §3 / §7)。
10916
+ *
10917
+ * updater の drain 終了フックに 1 つだけリスナーを登録し、バッチに載った絶対アドレスと
10918
+ * 宣言済み watch パスを突き合わせて発火する。**binding の有無に関係なくバッチへ載る**ので、
10919
+ * これが headless 購読の実体になる(binding 駆動の `$updatedCallback` との違い)。
10920
+ *
10921
+ * 実行順序(設計書 §3-2):
10922
+ * - 機構間は優先度で固定(`$updatedCallback` → `$watch` → `$streams` restart)。
10923
+ * `$updatedCallback` が先なのは binding 適用ループの内側で呼ばれる構造的必然。
10924
+ * - watch ハンドラ間は `$watch` の宣言順(entry.order)。利用者が順序に意思を持てる唯一の層。
10925
+ * - 同一パスの複数行は indexes 昇順。
10926
+ *
10927
+ * 収集と発火を 2 相に分ける理由:
10928
+ * 1. ハンドラ内の書き込みが registry / active 集合を同期的に変えうる(`_state` 再 set・切断)
10929
+ * ため、発火直前に live 再チェックが要る(`$streams` の restart hits と同型)。
10930
+ * 2. 上記の順序規約のために hits をソートする必要がある(バッチの反復順は enqueue 順)。
10931
+ */
10932
+ /**
10933
+ * この stateElement の `$watch` を有効化する(`State.connectedCallback` /接続中の
10934
+ * `_state` 再 set から呼ばれる。無効化は `clearWatchRegistry`)。
10935
+ *
10936
+ * `addActiveWatchStateElement` の薄いラッパではなく、**State が runtime を import する
10937
+ * 経路をここに一本化する**意味がある: drain リスナーの登録はこのモジュールの
10938
+ * 初期化副作用なので、registry だけを import すると発火機構ごと落ちる。
10939
+ * `$streams` の `startStreams` と対称の位置づけ。
10940
+ *
10941
+ * **宣言が 1 つも無ければ active 集合に入れない**(`startStreams` の
10942
+ * `entries.size === 0` early return と同型)。ここを無条件にすると active 集合が
10943
+ * 「接続中の全 `<wcs-state>`」になり、`fireWatchOnUpdateBatch` の early return が
10944
+ * 実アプリで効かなくなる = `$watch` 未使用アプリの drain にも収集ループが乗る
10945
+ * (ゼロコスト契約、設計書 §10 / 実装計画 P16)。
10946
+ */
10947
+ function startWatch(stateElement) {
10948
+ if (getWatchEntries(stateElement).size === 0) {
10949
+ return;
10950
+ }
10951
+ addActiveWatchStateElement(stateElement);
10952
+ primeComputedWatches(stateElement);
10953
+ }
10954
+ /**
10955
+ * watch 対象の computed(getter)を 1 回評価する(設計書 §5-2 の eager 化、C-3)。
10956
+ *
10957
+ * これが要るのは、getter の依存(dynamicDependency)が**評価時にしか張られない**ため。
10958
+ * 一度も評価されていない getter は依存グラフに載らず、依存の書き込みが walkDependency で
10959
+ * そのパスへ到達しないので、バッチにも載らず watch が永久に発火しない。ここで 1 回
10960
+ * 読むことで依存が張られ、同時に `prev` の初期スナップショットが埋まる。
10961
+ *
10962
+ * **これが「watch した getter は lazy でなくなる」の実体**であり、設計書 §5-2 で
10963
+ * 規範として明記している副作用(毎バッチ評価・例外の表面化・依存の再登録)の起点。
10964
+ *
10965
+ * ワイルドカードを含む getter パス(`items.*.tax` など)は対象外: 初回評価に行ごとの
10966
+ * indexes が要り、全行評価は宣言しただけでリスト全体を舐めることになる。この形は
10967
+ * 「DOM にバインドされていれば発火する」ままとし、§5-3 に制約として書く。
10968
+ */
10969
+ function primeComputedWatches(stateElement) {
10970
+ // 宣言が 1 つ以上あることは startWatch が保証済み
10971
+ const targets = [];
10972
+ for (const entry of getWatchEntries(stateElement).values()) {
10973
+ if (isScalarComputed(stateElement, entry)) {
10974
+ targets.push(entry);
10975
+ }
10976
+ }
10977
+ if (targets.length === 0) {
10978
+ // getter を watch していないなら createState ごと省く(宣言の大半はこちら)
10979
+ return;
10980
+ }
10981
+ stateElement.createState("readonly", (state) => {
10982
+ for (const entry of targets) {
10983
+ try {
10984
+ setComputedSnapshot(stateElement, absoluteAddressOf(stateElement, entry), state[entry.path]);
10985
+ }
10986
+ catch (e) {
10987
+ // 初回評価の throw は接続を巻き添えにしない(発火時と同じ隔離方針、§7-1)
10988
+ reportWatchError(stateElement, entry.path, "prime", e);
10989
+ }
10990
+ }
10991
+ });
10992
+ }
10993
+ /** ワイルドカードを含まない watch パスの絶対アドレス(listIndex は常に null) */
10994
+ function absoluteAddressOf(stateElement, entry) {
10995
+ return createAbsoluteStateAddress(getAbsolutePathInfo(stateElement, entry.pathInfo), null);
10996
+ }
10997
+ /**
10998
+ * 前回評価値のスナップショット台帳(computedSnapshots)に載せる entry か。
10999
+ *
11000
+ * ワイルドカードを含む getter を**除く**のが要点。除かないと台帳のキーが行ごとの
11001
+ * 絶対アドレス(= listIndex を強参照)になり、prune 経路が `_state` 再 set しか
11002
+ * 無いため、行が入れ替わり続けるページで単調増加する(リスト置換 5 回で 2→10 件を実測)。
11003
+ * そもそもワイルドカード getter は eager 化の対象外(設計書 §5-3)なので、
11004
+ * 「初回評価もしない・前回値も持たない」で primeComputedWatches と対称になる。
11005
+ * この形の `prev` は常に undefined(getter は setByAddress を通らない)。
11006
+ */
11007
+ function isScalarComputed(stateElement, entry) {
11008
+ return entry.pathInfo.wildcardCount === 0 && stateElement.getterPaths.has(entry.path);
11009
+ }
11010
+ /**
11011
+ * throw を報告する(設計書 §7-1)。
11012
+ *
11013
+ * `console.error` だけだと **devtools からは「静かに握られた失敗」が見えない**。
11014
+ * watch は drain フックを `$streams` と共有しており、例外を watch 側で閉じるのが
11015
+ * 前提なので、閉じた事実をここで観測可能にしておく必要がある。
11016
+ * イベント生成は必ず `devtoolsSink !== null` の内側で行う(sink のコスト規範)。
11017
+ */
11018
+ const WATCH_ERROR_SUBJECT = {
11019
+ prime: "initial evaluation of",
11020
+ evaluate: "evaluation of",
11021
+ handler: "handler for",
11022
+ };
11023
+ function reportWatchError(stateElement, path, phase, error) {
11024
+ console.error(`[@wcstack/state] $watch ${WATCH_ERROR_SUBJECT[phase]} "${path}" threw.`, error);
11025
+ if (devtoolsSink !== null) {
11026
+ devtoolsSink({
11027
+ type: "state:watch-error",
11028
+ phase,
11029
+ stateName: stateElement.name,
11030
+ path,
11031
+ error,
11032
+ });
11033
+ }
11034
+ }
11035
+ function fireWatchOnUpdateBatch(batch) {
11036
+ const activeStateElements = getActiveWatchStateElements();
11037
+ try {
11038
+ if (activeStateElements.size === 0) {
11039
+ // watch 未使用アプリの drain に配列・イテレータ割り当てのコストを載せない。
11040
+ // ここも finally を通す: 宣言済みの state が切断されている間(active からは
11041
+ // 外れるが watchPaths は残る)の書き込みで台帳に旧値が積まれるため、
11042
+ // クリアを早期 return の外に置くと次のバッチどころか永久に残る。
11043
+ return;
11044
+ }
11045
+ const depth = consumeWatchChainDepth();
11046
+ if (depth > MAX_WATCH_CHAIN_DEPTH) {
11047
+ // 打ち切るのは watch の発火のみ。値と binding 適用は巻き戻さない
11048
+ // (伝播 hop 上限超過時の quarantine と同じ姿勢、§7-2)。
11049
+ const paths = Array.from(batch, (absAddress) => absAddress.absolutePathInfo.pathInfo.path);
11050
+ console.error(`[@wcstack/state] $watch chain depth limit exceeded; watch handlers for this batch were skipped.`, { maxDepth: MAX_WATCH_CHAIN_DEPTH, paths });
11051
+ if (devtoolsSink !== null) {
11052
+ devtoolsSink({ type: "state:watch-chain-limit", maxDepth: MAX_WATCH_CHAIN_DEPTH, paths });
11053
+ }
11054
+ return;
11055
+ }
11056
+ // --- 収集フェーズ ---
11057
+ const hits = [];
11058
+ for (const absAddress of batch) {
11059
+ // stateName 文字列ではなく stateElement 参照で引く。AbsolutePathInfo は
11060
+ // stateElement 単位でキャッシュされるので、同名 state が複数の rootNode に
11061
+ // 居ても取り違えない(address/AbsolutePathInfo.ts)。他 state のアドレスは
11062
+ // ここで自然に落ちる = 越境しない(設計 D8)。
11063
+ const stateElement = absAddress.absolutePathInfo.stateElement;
11064
+ if (!activeStateElements.has(stateElement)) {
11065
+ continue;
11066
+ }
11067
+ const entry = getWatchEntries(stateElement).get(absAddress.absolutePathInfo.pathInfo.path);
11068
+ if (typeof entry === "undefined") {
11069
+ continue;
11070
+ }
11071
+ let indexes = [];
11072
+ if (entry.pathInfo.wildcardCount > 0) {
11073
+ if (absAddress.listIndex === null) {
11074
+ // ワイルドカードパスなのに行が特定できないヒット(リストの依存展開で載る
11075
+ // 中間アドレス等)。indexes を空のまま発火すると cur の解決($resolve)が
11076
+ // 「indexes 不足」で throw し、例外隔離に落ちて console.error だけが残る。
11077
+ // 行が定まらない以上ハンドラに渡せる意味が無いので、収集段階で落とす。
11078
+ continue;
11079
+ }
11080
+ indexes = getScopedIndexes(absAddress.listIndex, entry.pathInfo.wildcardCount);
11081
+ }
11082
+ hits.push({ stateElement, entry, absAddress, indexes });
11083
+ }
11084
+ if (hits.length === 0) {
11085
+ return;
11086
+ }
11087
+ hits.sort(compareHits);
11088
+ // --- 発火フェーズ ---
11089
+ beginWatchFiring(depth);
11090
+ try {
11091
+ for (const hit of hits) {
11092
+ // 先行ハンドラが同期的に切断や `_state` 再 set を行い得るため、発火直前に
11093
+ // 「まだ active か」「entry が現行 registry のものか」を再確認する。
11094
+ if (!activeStateElements.has(hit.stateElement) ||
11095
+ getWatchEntries(hit.stateElement).get(hit.entry.path) !== hit.entry) {
11096
+ continue;
11097
+ }
11098
+ fireOne(hit);
11099
+ }
11100
+ }
11101
+ finally {
11102
+ endWatchFiring();
11103
+ }
11104
+ }
11105
+ finally {
11106
+ // 旧値台帳はこの drain 限りのもの。次のバッチへ持ち越さない(§4-1)。
11107
+ clearPrevValues();
11108
+ }
11109
+ }
11110
+ /**
11111
+ * 層 2(宣言順)→ 層 3(indexes 昇順)の順に比較する(設計書 §3-3)。
11112
+ */
11113
+ function compareHits(a, b) {
11114
+ if (a.entry.order !== b.entry.order) {
11115
+ return a.entry.order - b.entry.order;
11116
+ }
11117
+ const length = Math.min(a.indexes.length, b.indexes.length);
11118
+ for (let i = 0; i < length; i++) {
11119
+ if (a.indexes[i] !== b.indexes[i]) {
11120
+ return a.indexes[i] - b.indexes[i];
11121
+ }
11122
+ }
11123
+ return a.indexes.length - b.indexes.length;
11124
+ }
11125
+ /**
11126
+ * ハンドラ 1 つを発火する。**例外はここで閉じる**(設計書 §7-1)。
11127
+ *
11128
+ * drain リスナーの throw は握りつぶさない契約(updater.ts)なので、watch 側で捕まえないと
11129
+ * 1 つのユーザー例外が他の watch と `$streams` の restart を巻き添えにする。
11130
+ * `$connectedCallback` / `$updatedCallback` の loud fail とは意図的に異なる扱い。
11131
+ *
11132
+ * 報告は throw 元で分ける: `cur` の解決(watch した getter の強制評価 = §5-2 の副作用 b)と
11133
+ * ハンドラ本体では原因も直し方も違うため、同じ文言に丸めない。
11134
+ */
11135
+ function fireOne(hit) {
11136
+ const { stateElement, entry, absAddress, indexes } = hit;
11137
+ // スカラ getter は setByAddress を通らないので旧値台帳に載らない。前回評価値の
11138
+ // スナップショット(バッチを跨いで生きる別台帳)から prev を取る(§5-2)。
11139
+ const isComputed = isScalarComputed(stateElement, entry);
11140
+ try {
11141
+ stateElement.createState("writable", (state) => {
11142
+ let cur;
11143
+ try {
11144
+ // 強制評価はここ。dirty なら再計算され、その結果が cur になる
11145
+ cur = readCurrentValue(state, entry, indexes);
11146
+ }
11147
+ catch (e) {
11148
+ // cur が得られない以上ハンドラは呼べない。次の hit へ進む
11149
+ reportWatchError(stateElement, entry.path, "evaluate", e);
11150
+ return;
11151
+ }
11152
+ const prev = isComputed ? getComputedSnapshot(stateElement, absAddress) : getPrevValue(absAddress);
11153
+ if (isComputed) {
11154
+ // ハンドラ本体が throw しても次回の prev は「今回の評価値」であるべきなので、
11155
+ // handler 呼び出しより前に更新する
11156
+ setComputedSnapshot(stateElement, absAddress, cur);
11157
+ }
11158
+ entry.handler.call(state, cur, prev, ...indexes);
11159
+ });
11160
+ }
11161
+ catch (e) {
11162
+ reportWatchError(stateElement, entry.path, "handler", e);
11163
+ }
11164
+ }
11165
+ function readCurrentValue(state, entry, indexes) {
11166
+ if (entry.pathInfo.wildcardCount === 0) {
11167
+ return state[entry.path];
11168
+ }
11169
+ // ワイルドカードを含むパスは素の読みでは解決できない。getScopedIndexes が返した列は
11170
+ // そのまま $resolve の引数として使える(list/wildcardLevel.ts の往復契約)。
11171
+ return state.$resolve(entry.path, indexes);
11172
+ }
11173
+ // 優先度で `$streams` の restart より先に固定する(設計書 §3-2 層 1)。import 順には依存しない。
11174
+ registerUpdateBatchListener(fireWatchOnUpdateBatch, WATCH_LISTENER_PRIORITY);
10073
11175
 
10074
11176
  function getterFn(name) {
10075
11177
  return function () {
@@ -10180,6 +11282,20 @@ function getAllPropertyDescriptors(obj) {
10180
11282
  * 双方向バインド・spread・initialSync の bindable 判定が**警告なしで**丸ごと死ぬ。
10181
11283
  * 自前のファクトリが自前の reader に棄却される状態なので、生成前に落とす。
10182
11284
  */
11285
+ /**
11286
+ * did-you-mean の候補(エラーパス専用)。`$` 予約名と継承 `constructor` を除き、
11287
+ * `$bindables` には値プロパティだけ・`$commands` にはメソッドだけを提案する
11288
+ * (逆側を提案すると次は「is a method / is not a method」エラーに嵌まるため)。
11289
+ */
11290
+ function dccCandidateNames(descriptors, kind) {
11291
+ return Object.keys(descriptors).filter((key) => {
11292
+ if (key.startsWith("$") || key === "constructor") {
11293
+ return false;
11294
+ }
11295
+ const isMethod = typeof descriptors[key].value === "function";
11296
+ return kind === "method" ? isMethod : !isMethod;
11297
+ });
11298
+ }
10183
11299
  function readNameList(state, declarationName) {
10184
11300
  const declared = state[declarationName];
10185
11301
  if (typeof declared === "undefined") {
@@ -10232,7 +11348,7 @@ function processDccDeclarations(state) {
10232
11348
  streamBackedBindables.push(name);
10233
11349
  continue;
10234
11350
  }
10235
- raiseError(`${STATE_BINDABLES_NAME} entry "${name}" is not declared on the state.`);
11351
+ raiseError(`${STATE_BINDABLES_NAME} entry "${name}" is not declared on the state.${didYouMean(name, dccCandidateNames(descriptors, "value"))}`);
10236
11352
  }
10237
11353
  if (typeof descriptor.value === "function") {
10238
11354
  raiseError(`${STATE_BINDABLES_NAME} entry "${name}" is a method. Declare it in ${STATE_COMMANDS_NAME} instead.`);
@@ -10241,7 +11357,7 @@ function processDccDeclarations(state) {
10241
11357
  for (const name of commands) {
10242
11358
  const descriptor = descriptors[name];
10243
11359
  if (typeof descriptor === "undefined") {
10244
- raiseError(`${STATE_COMMANDS_NAME} entry "${name}" is not declared on the state.`);
11360
+ raiseError(`${STATE_COMMANDS_NAME} entry "${name}" is not declared on the state.${didYouMean(name, dccCandidateNames(descriptors, "method"))}`);
10245
11361
  }
10246
11362
  if (typeof descriptor.value !== "function") {
10247
11363
  raiseError(`${STATE_COMMANDS_NAME} entry "${name}" is not a method. Declare it in ${STATE_BINDABLES_NAME} instead.`);
@@ -11270,13 +12386,22 @@ function selectExpansionIndexes(context, sourcePath, _lastValue, _newValue, list
11270
12386
  const EMPTY_PATH_INFOS = [];
11271
12387
  /**
11272
12388
  * 位置だけが変わった行(movedRows)で展開すべきパス群を求める。
11273
- * `${listPath}.*` の静的 subtree を辿り、$1 等を読んだ実績のある getter
12389
+ * `${listPath}.*` 配下にある、$1 等を読んだ実績のある getter
11274
12390
  * (indexDependentGetterPaths)だけを返す。行の同一性・listIndex は保たれ
11275
12391
  * index 以外の入力が不変なので、index を読まない getter / 値パスは再評価不要。
11276
12392
  * 戻り値:
11277
12393
  * - IPathInfo[](空可): この各パスだけを行の listIndex で展開する
11278
12394
  * - null: ネストしたワイルドカード配下に index 依存 getter がある
11279
12395
  * (listIndex の階数が合わず個別展開できない)→ 呼び出し側で行全体展開に倒す
12396
+ *
12397
+ * 配下判定は staticMap の subtree 走査ではなく indexDependentGetterPaths 側の
12398
+ * プレフィックス照合で行う。静的依存グラフは `State.setPathInfo` が
12399
+ * 「バインドされたパスから親方向へ」張るため、DOM にバインドされていない中間
12400
+ * getter(`.label` だけを描画し `.rank` は `.label` からしか読まれない綴り)は
12401
+ * subtree に現れない。そこを走査すると index 依存 getter を取りこぼし、
12402
+ * 「index を読む getter が subtree に無い=位置のみ変わった行の値は不変」という
12403
+ * 呼び出し側の判断が偽になって、移動行の getter が古い値のまま残る。
12404
+ * この集合は $1 を読んだ getter の数しか持たないので、走査コストも subtree より小さい。
11280
12405
  */
11281
12406
  function getMovedRowExpansionPaths(context, wildcardPath, depPathInfo) {
11282
12407
  const indexGetters = context.stateElement.indexDependentGetterPaths;
@@ -11284,26 +12409,16 @@ function getMovedRowExpansionPaths(context, wildcardPath, depPathInfo) {
11284
12409
  return EMPTY_PATH_INFOS;
11285
12410
  }
11286
12411
  let result = null;
11287
- const queue = [wildcardPath];
11288
- const seen = new Set(queue);
11289
- for (let i = 0; i < queue.length; i++) {
11290
- const path = queue[i];
11291
- if (indexGetters.has(path)) {
11292
- const pathInfo = getPathInfo(path);
11293
- if (pathInfo.wildcardCount !== depPathInfo.wildcardCount) {
11294
- return null;
11295
- }
11296
- (result ??= []).push(pathInfo);
12412
+ const prefix = wildcardPath + DELIMITER;
12413
+ for (const path of indexGetters) {
12414
+ if (path !== wildcardPath && !path.startsWith(prefix)) {
12415
+ continue;
11297
12416
  }
11298
- const children = context.staticMap.get(path);
11299
- if (children) {
11300
- for (const child of children) {
11301
- if (!seen.has(child)) {
11302
- seen.add(child);
11303
- queue.push(child);
11304
- }
11305
- }
12417
+ const pathInfo = getPathInfo(path);
12418
+ if (pathInfo.wildcardCount !== depPathInfo.wildcardCount) {
12419
+ return null;
11306
12420
  }
12421
+ (result ??= []).push(pathInfo);
11307
12422
  }
11308
12423
  return result ?? EMPTY_PATH_INFOS;
11309
12424
  }
@@ -11531,6 +12646,21 @@ function walkDependency(stateName, stateElement, startAddress, staticDependency,
11531
12646
  * - finallyで必ず更新情報を登録し、再描画や依存解決に利用
11532
12647
  * - getter/setter経由のスコープ切り替えも考慮した設計
11533
12648
  */
12649
+ /**
12650
+ * `$watch` の `prev` 台帳へ旧値を記録する(docs/state-watch-hook-design.md §4-1)。
12651
+ *
12652
+ * same-value guard が既に読んだ旧値だけを使い、watch のための追加読みはしない。
12653
+ * `$watch` 未宣言時のコストは `watchPaths` の null 判定 1 個に収める(§10)。
12654
+ */
12655
+ function recordWatchPrevValue(stateElement, path, absAddress, oldValue, hasOldValue) {
12656
+ const watchPaths = stateElement.watchPaths;
12657
+ if (watchPaths == null || !hasOldValue) {
12658
+ return;
12659
+ }
12660
+ if (watchPaths.has(path)) {
12661
+ recordPrevValue(absAddress, oldValue);
12662
+ }
12663
+ }
11534
12664
  // Phase 3: 書き込み時点の因果 context を update record に付与する。
11535
12665
  // binding 経由の書き込みは呼び出し元の dynamic scope から context を引き継ぎ、
11536
12666
  // binding 外からの API update は新しい transaction を開始する(設計書 §4 規則 1)。
@@ -11764,6 +12894,7 @@ function setByAddressCore(target, address, value, receiver, handler, keyedMergeP
11764
12894
  hasOldValue: devHasOldValue,
11765
12895
  });
11766
12896
  }
12897
+ recordWatchPrevValue(stateElement, path, absAddress, devOldValue, devHasOldValue);
11767
12898
  try {
11768
12899
  if (key === undefined) {
11769
12900
  raiseError(`address.listIndex?.index is undefined path: ${path}`);
@@ -11814,6 +12945,7 @@ function setByAddressCore(target, address, value, receiver, handler, keyedMergeP
11814
12945
  hasOldValue: devHasOldValue,
11815
12946
  });
11816
12947
  }
12948
+ recordWatchPrevValue(stateElement, path, absAddress, devOldValue, devHasOldValue);
11817
12949
  try {
11818
12950
  if (isSwappable) {
11819
12951
  return _setByAddressWithSwap(target, address, absAddress, value, receiver, handler, keyedMergePath);
@@ -12558,14 +13690,19 @@ class InnerStateProxyHandler {
12558
13690
  */
12559
13691
  _outerLoopContext(innerPathInfo, outerAbsPathInfo) {
12560
13692
  const outerWildcardCount = outerAbsPathInfo.pathInfo.wildcardCount;
13693
+ // 段数の照合は外側スコープの**実 arity**(パスの段数 + そのスコープの Δ)で行う。
13694
+ // 境界 1 枚なら外側は Δ=0 で従来と同値、2 枚以上あるときに中間スコープの Δ を
13695
+ // 数えないと候補が両方とも外れて loopContext が null になる(§1.12)。
13696
+ // 添字(wildcardPaths)に使うのは Δ を含まない段数のままであることに注意。
13697
+ const outerArity = getScopeArity(outerAbsPathInfo.stateElement, outerAbsPathInfo.pathInfo);
12561
13698
  const nodeLoopContext = getLoopContextByNode(this._webComponent);
12562
- if (nodeLoopContext !== null && nodeLoopContext.listIndex.length === outerWildcardCount) {
13699
+ if (nodeLoopContext !== null && nodeLoopContext.listIndex.length === outerArity) {
12563
13700
  return nodeLoopContext;
12564
13701
  }
12565
13702
  if (outerWildcardCount > 0) {
12566
13703
  const address = getCrossBoundaryAddress(this._innerStateElement, innerPathInfo.path);
12567
13704
  const listIndex = address?.listIndex ?? null;
12568
- if (listIndex !== null && listIndex.length === outerWildcardCount) {
13705
+ if (listIndex !== null && listIndex.length === outerArity) {
12569
13706
  const outerWildcardPath = outerAbsPathInfo.pathInfo.wildcardPaths[outerWildcardCount - 1];
12570
13707
  return createStateAddress(getPathInfo(outerWildcardPath), listIndex);
12571
13708
  }
@@ -12837,6 +13974,8 @@ class State extends HTMLElementBase {
12837
13974
  _dynamicDependency = new Map();
12838
13975
  _staticDependency = new Map();
12839
13976
  _pathSet = new Set();
13977
+ // `$watch` 宣言の監視対象パス。宣言が無ければ null(setByAddress のゼロコスト契約)
13978
+ _watchPaths = null;
12840
13979
  _version = 0;
12841
13980
  _rootNode = null;
12842
13981
  _boundComponent = null;
@@ -12916,10 +14055,21 @@ class State extends HTMLElementBase {
12916
14055
  // $listKeys: 宣言が無ければ null のままで、setByAddress のキー突合経路には
12917
14056
  // 一切入らない(docs/state-list-key-design.md §7-1)。再 set で必ず置き換える。
12918
14057
  this._listKeys = processListKeysDeclaration(value);
14058
+ // $watch: 旧宣言のハンドラが残らないよう registry を落としてから新宣言を解析する。
14059
+ // _pathSet.clear() の後であること(依存グラフ登録をやり直す必要がある、
14060
+ // docs/state-watch-hook-design.md §8)。宣言が無ければ watchPaths は null で、
14061
+ // setByAddress の旧値キャプチャには一切入らない(§10 のゼロコスト契約)。
14062
+ clearWatchRegistry(this);
14063
+ // computed の前回評価値も宣言と寿命を共にする(旧宣言の値を新しい watch の
14064
+ // prev として渡さない)。切断では消さない — 再接続の初回評価が上書きする。
14065
+ clearComputedSnapshots(this);
14066
+ this._watchPaths = processWatchDeclaration(this, value);
12919
14067
  // 接続中の再 set(S13)は新宣言で即再起動する。
12920
14068
  // 初回(_initialize 中)は _initialized が false なのでここでは起動されず、
12921
14069
  // connectedCallback 側の startStreams($connectedCallback 完了後)が担う。
12922
14070
  if (this._initialized && this._rootNode !== null && !inSsr()) {
14071
+ // watch は stream より先に有効化する(stream の起動時書き込みを観測できるように)
14072
+ startWatch(this);
12923
14073
  startStreams(this);
12924
14074
  // $connectedCallback 実行中の再 set(setInitialState)では、ここで新宣言が
12925
14075
  // 起動済みのため connectedCallback 末尾の startStreams を skip させる。
@@ -13057,6 +14207,36 @@ class State extends HTMLElementBase {
13057
14207
  bindWebComponent(this, this._boundComponent, this._boundComponentStateProp, state);
13058
14208
  }
13059
14209
  }
14210
+ /**
14211
+ * Light DOM の mapped コンポーネントが、自分のサブツリーのバインディングを張る(§1.13)。
14212
+ *
14213
+ * Shadow DOM 形では子スコープが別 rootNode にあり、`setStateElementByName` の初回登録から
14214
+ * その root ぶんの `buildBindings` が別パスとして起動する。Light DOM ではホストと同じ root に
14215
+ * いるためそのパスが存在せず、かといってホストのパスに混ぜると `@name` の解決が
14216
+ * この要素の名前登録より先に来てしまう。そこで `getSubscriberNodes` がホスト側の走査から
14217
+ * このサブツリーを外し、名前登録が済んだここで同じことを自前で行う。
14218
+ *
14219
+ * `{{ }}` の変換だけはホストのパスが root 全体に対して済ませている(純粋にテキスト操作で
14220
+ * state に依存しないため)。構造フラグメントの収集は fragment info を rootNode + state 名で
14221
+ * 登録するので state 依存であり、ホストのパスからは外してここで走らせる。
14222
+ *
14223
+ * ループ文脈を null で渡すのは Shadow DOM 形(`initializeBindings(shadowRoot, null)`)と
14224
+ * 揃えるため —— 子孫の `getLoopContextByNode` はコンポーネント要素まで遡って
14225
+ * 親スコープの行を見つける。
14226
+ */
14227
+ _initializeLightDomComponentScope() {
14228
+ const component = this._boundComponent;
14229
+ if (component === null || this.parentNode !== component) {
14230
+ // Shadow DOM 形(parentNode が ShadowRoot)は対象外
14231
+ return;
14232
+ }
14233
+ if (!component.hasAttribute(config.bindAttributeName)) {
14234
+ // plain 形はホストのパスに含まれたままなので、ここで張ると二重になる
14235
+ return;
14236
+ }
14237
+ collectStructuralFragments(this._rootNode, component);
14238
+ initializeBindings(component, null);
14239
+ }
13060
14240
  /**
13061
14241
  * mapped な `bind-component` が切断 → 再接続したときに、束ねているパスを読み直させる(§1.9)。
13062
14242
  *
@@ -13158,6 +14338,9 @@ class State extends HTMLElementBase {
13158
14338
  await this._initializeBindWebComponent();
13159
14339
  await this._initialize();
13160
14340
  this._initialized = true;
14341
+ // 名前登録(_initialize の末尾)が済んだこの時点でなければ、子スコープの
14342
+ // `@name` 参照が解決できない(§1.13)
14343
+ this._initializeLightDomComponentScope();
13161
14344
  this._resolveInitialize?.();
13162
14345
  }
13163
14346
  else if (!this._dcc && getStateElementByName(this._rootNode, this._name) !== this) {
@@ -13209,6 +14392,19 @@ class State extends HTMLElementBase {
13209
14392
  // _streamsStartedGeneration ガード: $connectedCallback 内の setInitialState
13210
14393
  // (接続中の再 set)で _state セッター側が新宣言を起動済みの場合は skip する
13211
14394
  // (skip しないと同一 connect サイクルで source が 2 回起動する、設計書 §2-3)。
14395
+ // $watch の有効化($connectedCallback 完了後 = 初期化中の書き込みは購読対象外)。
14396
+ // ガードは startStreams と同じ理由で必要(await 中の切断・再接続)。SSR では
14397
+ // 走らせない — ハンドラの副作用がサーバとクライアントで二重に実行されるため
14398
+ // (docs/state-watch-hook-design.md §11)。
14399
+ // startStreams より先に呼ぶ: stream の起動時書き込み(initial リセット・status 遷移)は
14400
+ // watch から観測できるべきで、逆向きは要らない。
14401
+ // 再入不要: 接続中の _state 再 set は _state セッター側で startWatch 済みだが、
14402
+ // startWatch は Set への add で冪等なので $streams のような世代ガードは要らない。
14403
+ if (!inSsr() &&
14404
+ this._rootNode !== null &&
14405
+ connectGeneration === this._connectGeneration) {
14406
+ startWatch(this);
14407
+ }
13212
14408
  if (!inSsr() &&
13213
14409
  this._rootNode !== null &&
13214
14410
  connectGeneration === this._connectGeneration &&
@@ -13238,6 +14434,10 @@ class State extends HTMLElementBase {
13238
14434
  // registry は残るため再接続後の初回アクセスで同内容の proxy が再生成される)。
13239
14435
  abortAllStreams(this);
13240
14436
  clearStreamNamespace(this);
14437
+ // watch は発火対象から外すだけで registry は保持する(stream の abortAllStreams と
14438
+ // 同じ二段構え、設計書 §9)。registry まで捨てると、_state セッターが再度走らない
14439
+ // 再接続で宣言を作り直せず watch が二度と発火しない。
14440
+ deactivateWatch(this);
13241
14441
  this._rootNode = null;
13242
14442
  }
13243
14443
  }
@@ -13257,6 +14457,9 @@ class State extends HTMLElementBase {
13257
14457
  get listKeys() {
13258
14458
  return this._listKeys;
13259
14459
  }
14460
+ get watchPaths() {
14461
+ return this._watchPaths;
14462
+ }
13260
14463
  get elementPaths() {
13261
14464
  return this._elementPaths;
13262
14465
  }
@@ -13568,6 +14771,8 @@ const builtinFilterMeta = {
13568
14771
  mul: { description: "乗算", hasArgs: true, resultType: "number", acceptTypes: ["number"], minArgs: 1, maxArgs: 1, argTypes: ["number"] },
13569
14772
  div: { description: "除算", hasArgs: true, resultType: "number", acceptTypes: ["number"], minArgs: 1, maxArgs: 1, argTypes: ["number"] },
13570
14773
  mod: { description: "剰余", hasArgs: true, resultType: "number", acceptTypes: ["number"], minArgs: 1, maxArgs: 1, argTypes: ["number"] },
14774
+ abs: { description: "絶対値", hasArgs: false, resultType: "number", acceptTypes: ["number"], minArgs: 0, maxArgs: 0 },
14775
+ clamp: { description: "範囲内に丸める (min,max)", hasArgs: true, resultType: "number", acceptTypes: ["number"], minArgs: 2, maxArgs: 2, argTypes: ["number", "number"] },
13571
14776
  // 数値フォーマット
13572
14777
  fix: { description: "固定小数点表記", hasArgs: true, resultType: "string", acceptTypes: ["number"], minArgs: 0, maxArgs: 1, argTypes: ["number"] },
13573
14778
  locale: { description: "ロケール形式で数値フォーマット", hasArgs: true, resultType: "string", acceptTypes: ["number"], minArgs: 0, maxArgs: 1, argTypes: ["string"] },
@@ -13581,6 +14786,8 @@ const builtinFilterMeta = {
13581
14786
  pad: { description: "パディング (length[,char])", hasArgs: true, resultType: "string", acceptTypes: ["string"], minArgs: 1, maxArgs: 2, argTypes: ["number", "string"] },
13582
14787
  rep: { description: "繰り返し (count)", hasArgs: true, resultType: "string", acceptTypes: ["string"], minArgs: 1, maxArgs: 1, argTypes: ["number"] },
13583
14788
  rev: { description: "文字順を反転", hasArgs: false, resultType: "string", acceptTypes: ["string"], minArgs: 0, maxArgs: 0 },
14789
+ truncate: { description: "切り詰めて省略記号 (length[,suffix])", hasArgs: true, resultType: "string", acceptTypes: ["string"], minArgs: 1, maxArgs: 2, argTypes: ["number", "string"] },
14790
+ join: { description: "配列を連結 ([separator])", hasArgs: true, resultType: "string", acceptTypes: ["array"], minArgs: 0, maxArgs: 1, argTypes: ["string"] },
13584
14791
  // 数値パース・丸め
13585
14792
  int: { description: "整数にパース", hasArgs: false, resultType: "number", acceptTypes: ["string", "number"], minArgs: 0, maxArgs: 0 },
13586
14793
  float: { description: "浮動小数点数にパース", hasArgs: false, resultType: "number", acceptTypes: ["string", "number"], minArgs: 0, maxArgs: 0 },
@@ -13588,11 +14795,15 @@ const builtinFilterMeta = {
13588
14795
  floor: { description: "切り下げ", hasArgs: true, resultType: "number", acceptTypes: ["number"], minArgs: 0, maxArgs: 1, argTypes: ["number"] },
13589
14796
  ceil: { description: "切り上げ", hasArgs: true, resultType: "number", acceptTypes: ["number"], minArgs: 0, maxArgs: 1, argTypes: ["number"] },
13590
14797
  percent: { description: "パーセンテージ形式", hasArgs: true, resultType: "string", acceptTypes: ["number"], minArgs: 0, maxArgs: 1, argTypes: ["number"] },
14798
+ // number だけでなく string も受ける。実用チェーンは fix / percent の後ろに繋がり、
14799
+ // それらは既に string を返すため(builtinFilters.ts の unit を参照)
14800
+ unit: { description: "単位(接尾辞)を付加", hasArgs: true, resultType: "string", acceptTypes: ["number", "string"], minArgs: 1, maxArgs: 1, argTypes: ["string"] },
13591
14801
  // 日付・時刻
13592
14802
  date: { description: "ロケール形式の日付", hasArgs: false, resultType: "string", acceptTypes: "any", minArgs: 0, maxArgs: 0 },
13593
14803
  time: { description: "ロケール形式の時刻", hasArgs: false, resultType: "string", acceptTypes: "any", minArgs: 0, maxArgs: 0 },
13594
14804
  datetime: { description: "ロケール形式の日時", hasArgs: false, resultType: "string", acceptTypes: "any", minArgs: 0, maxArgs: 0 },
13595
14805
  ymd: { description: "YYYY-MM-DD 形式", hasArgs: true, resultType: "string", acceptTypes: "any", minArgs: 0, maxArgs: 1, argTypes: ["string"] },
14806
+ hms: { description: "HH:MM:SS 形式", hasArgs: true, resultType: "string", acceptTypes: "any", minArgs: 0, maxArgs: 1, argTypes: ["string"] },
13596
14807
  // 真偽値・変換
13597
14808
  falsy: { description: "偽値か判定", hasArgs: false, resultType: "boolean", acceptTypes: "any", minArgs: 0, maxArgs: 0 },
13598
14809
  truthy: { description: "真値か判定", hasArgs: false, resultType: "boolean", acceptTypes: "any", minArgs: 0, maxArgs: 0 },
@@ -13636,6 +14847,27 @@ function getWcsManifest() {
13636
14847
  },
13637
14848
  // 正本 STRUCTURAL_BINDING_TYPE_SET から導出(手書きの二重定義を排除)。
13638
14849
  structuralDirectives: Array.from(STRUCTURAL_BINDING_TYPE_SET),
14850
+ modifiers: {
14851
+ flags: MODIFIER_FLAGS,
14852
+ keyValue: MODIFIER_KEYS,
14853
+ eventNamePrefix: EVENT_PROP_PREFIX,
14854
+ },
14855
+ indexParam: {
14856
+ prefix: INDEX_PARAM_PREFIX,
14857
+ maxDepth: MAX_WILDCARD_DEPTH,
14858
+ },
14859
+ bindingTypes: {
14860
+ elseKeyword: ELSE_KEYWORD,
14861
+ spread: SPREAD_PROP,
14862
+ eventPropertyPrefix: EVENT_PROP_PREFIX,
14863
+ propNamespaces: {
14864
+ eventToken: EVENT_TOKEN_NAMESPACE,
14865
+ command: COMMAND_NAMESPACE,
14866
+ class: CLASS_NAMESPACE,
14867
+ attr: ATTR_NAMESPACE,
14868
+ style: STYLE_NAMESPACE,
14869
+ },
14870
+ },
13639
14871
  },
13640
14872
  // 実装(Record のキー)から自動導出。手リストを持たない=ドリフトの構造的排除。
13641
14873
  filters: Object.keys(outputBuiltinFilters),
@@ -13654,6 +14886,7 @@ function getWcsManifest() {
13654
14886
  STATE_EVENT_TOKENS_NAME,
13655
14887
  STATE_ON_NAME,
13656
14888
  STATE_STREAMS_NAME,
14889
+ STATE_WATCH_NAME,
13657
14890
  STATE_LIST_KEYS_NAME,
13658
14891
  STATE_STREAM_STATUS_NAMESPACE_NAME,
13659
14892
  STATE_STREAM_ERROR_NAMESPACE_NAME,