@wcstack/state 1.26.0 → 1.27.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 = ';'; // 複数バインディングの区切り
@@ -164,11 +140,53 @@ const STATE_COMMAND_NAMESPACE_NAME = "$command";
164
140
  const STATE_EVENT_TOKENS_NAME = "$eventTokens";
165
141
  const STATE_ON_NAME = "$on";
166
142
  const STATE_STREAMS_NAME = "$streams";
143
+ const STATE_WATCH_NAME = "$watch";
167
144
  const STATE_LIST_KEYS_NAME = "$listKeys";
168
145
  const STATE_STREAM_STATUS_NAMESPACE_NAME = "$streamStatus";
169
146
  const STATE_STREAM_ERROR_NAMESPACE_NAME = "$streamError";
170
147
  const DCC_DEFINITION_ATTRIBUTE = "data-wc-definition";
171
148
 
149
+ const bindingPromiseByNode = new WeakMap();
150
+ // resolve 済みマーク。エントリ未生成のまま resolve されたノードは、後から
151
+ // wait された時に「生成して即 resolve」で追いつく。
152
+ const resolvedNodes = new WeakSet();
153
+ let id$1 = 0;
154
+ function getInitializeBindingPromiseByNode(node) {
155
+ let bindingPromise = bindingPromiseByNode.get(node) || null;
156
+ if (bindingPromise !== null) {
157
+ return bindingPromise;
158
+ }
159
+ let resolveFn = undefined;
160
+ const promise = new Promise((resolve) => {
161
+ resolveFn = resolve;
162
+ });
163
+ bindingPromise = {
164
+ id: ++id$1,
165
+ promise,
166
+ resolve: resolveFn
167
+ };
168
+ bindingPromiseByNode.set(node, bindingPromise);
169
+ if (resolvedNodes.has(node)) {
170
+ bindingPromise.resolve();
171
+ }
172
+ return bindingPromise;
173
+ }
174
+ async function waitInitializeBinding(node) {
175
+ const bindingPromise = getInitializeBindingPromiseByNode(node);
176
+ await bindingPromise.promise;
177
+ }
178
+ function resolveInitializedBinding(node) {
179
+ // ホットパス: リスト行では全 subscriber ノードがここを通るが、await する消費者
180
+ // (boundComponent / shadowRoot host)はほぼ居ない。既存エントリが無ければ
181
+ // Promise+closure を生成せず resolve 済みマークだけ残す(15 万個級の割り当て削減)。
182
+ const existing = bindingPromiseByNode.get(node);
183
+ if (typeof existing !== "undefined") {
184
+ existing.resolve();
185
+ return;
186
+ }
187
+ resolvedNodes.add(node);
188
+ }
189
+
172
190
  const _cache$4 = new Map();
173
191
  let id = 0;
174
192
  function getPathInfo(path) {
@@ -683,6 +701,15 @@ function valueMustBeBoolean(fnName) {
683
701
  function valueMustBeDate(fnName) {
684
702
  raiseError(`filter ${fnName} requires a date value`);
685
703
  }
704
+ /**
705
+ * Throws error when filter requires array value but non-array provided.
706
+ *
707
+ * @param fnName - Name of the filter function
708
+ * @returns Never returns (always throws)
709
+ */
710
+ function valueMustBeArray(fnName) {
711
+ raiseError(`filter ${fnName} requires an array value`);
712
+ }
686
713
 
687
714
  /**
688
715
  * builtinFilters.ts
@@ -695,7 +722,7 @@ function valueMustBeDate(fnName) {
695
722
  * - Designed for common use as both input and output filters
696
723
  *
697
724
  * 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.
725
+ * - 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
726
  * - Rich type checking and error handling for option values
700
727
  * - Centralized management of filter functions with FilterWithOptions type, easy to extend
701
728
  * - Dynamic retrieval of filter functions from filter names and options via builtinFilterFn
@@ -928,6 +955,48 @@ const mod = (options) => {
928
955
  return value % Number(opt);
929
956
  };
930
957
  };
958
+ /**
959
+ * Absolute value filter - returns the magnitude of a number.
960
+ *
961
+ * @param options - Unused
962
+ * @returns Filter function that returns the absolute value
963
+ */
964
+ const abs = (_options) => {
965
+ return (value) => {
966
+ if (typeof value !== 'number') {
967
+ valueMustBeNumber('abs');
968
+ }
969
+ return Math.abs(value);
970
+ };
971
+ };
972
+ /**
973
+ * Clamp filter - constrains a number to the inclusive range [min, max].
974
+ *
975
+ * Saturating conversion in the same family as round/floor/ceil, so it stays on
976
+ * the wire rather than in state. Pairs with `unit` for style bindings:
977
+ * `style.width: ratio|clamp(0,1)|percent(0)`.
978
+ *
979
+ * @param options - Array with minimum as first element and maximum as second (both required)
980
+ * @returns Filter function that returns the clamped number
981
+ */
982
+ const clamp = (options) => {
983
+ const opt1 = options?.[0] ?? optionsRequired('clamp');
984
+ if (!validateNumberString(opt1)) {
985
+ optionMustBeNumber('clamp');
986
+ }
987
+ const opt2 = options?.[1] ?? optionsRequired('clamp');
988
+ if (!validateNumberString(opt2)) {
989
+ optionMustBeNumber('clamp');
990
+ }
991
+ const min = Number(opt1);
992
+ const max = Number(opt2);
993
+ return (value) => {
994
+ if (typeof value !== 'number') {
995
+ valueMustBeNumber('clamp');
996
+ }
997
+ return Math.min(Math.max(value, min), max);
998
+ };
999
+ };
931
1000
  /**
932
1001
  * Fixed decimal filter - formats number to fixed decimal places.
933
1002
  *
@@ -1194,6 +1263,76 @@ const percent = (options) => {
1194
1263
  return `${(value * 100).toFixed(Number(opt))}%`;
1195
1264
  };
1196
1265
  };
1266
+ /**
1267
+ * Unit filter - appends a CSS unit (or any suffix) to the value.
1268
+ *
1269
+ * A number alone does nothing in CSS, so without this the unit has to be built in
1270
+ * state — which drags presentation into the source of truth, and in the worst case
1271
+ * forces a whole derived array just to carry `"42%"` strings.
1272
+ * `style.height: samples.*.cpu|clamp(0,100)|fix(0)|unit(%)` keeps it on the wire.
1273
+ *
1274
+ * Accepts strings as well as numbers **on purpose**: the useful chains run through
1275
+ * `fix` / `percent`, which already return strings. Rejecting non-numbers here would
1276
+ * break exactly the combination this filter exists for.
1277
+ *
1278
+ * `null` / `undefined` pass through untouched rather than becoming `"undefinedpx"`,
1279
+ * so the binding layer's "undefined skips the write, null clears" semantics survive.
1280
+ *
1281
+ * @param options - Array with the unit/suffix as first element (required)
1282
+ * @returns Filter function that returns the value with the unit appended
1283
+ */
1284
+ const unit = (options) => {
1285
+ const opt = options?.[0] ?? optionsRequired('unit');
1286
+ return (value) => {
1287
+ if (value === null || typeof value === 'undefined') {
1288
+ return value;
1289
+ }
1290
+ return String(value) + opt;
1291
+ };
1292
+ };
1293
+ /**
1294
+ * Join filter - joins array elements into a string.
1295
+ *
1296
+ * The default separator is `", "` rather than `","`: a bare comma is what `String()`
1297
+ * already produces without any filter, so defaulting to it would make `|join` a no-op.
1298
+ *
1299
+ * @param options - Array with separator as first element (default: ', ')
1300
+ * @returns Filter function that returns the joined string
1301
+ */
1302
+ const join = (options) => {
1303
+ const opt = options?.[0] ?? ', ';
1304
+ return (value) => {
1305
+ if (!Array.isArray(value)) {
1306
+ valueMustBeArray('join');
1307
+ }
1308
+ return value.join(opt);
1309
+ };
1310
+ };
1311
+ /**
1312
+ * Truncate filter - shortens a string and appends an ellipsis.
1313
+ *
1314
+ * The length option counts **kept characters**, not the total including the suffix,
1315
+ * matching the existing `slice(0, n)` reading. A string at or below the limit is
1316
+ * returned untouched (no suffix).
1317
+ *
1318
+ * @param options - Array with max kept length as first element and suffix as second (default: '…')
1319
+ * @returns Filter function that returns the truncated string
1320
+ */
1321
+ const truncate = (options) => {
1322
+ const opt1 = options?.[0] ?? optionsRequired('truncate');
1323
+ if (!validateNumberString(opt1)) {
1324
+ optionMustBeNumber('truncate');
1325
+ }
1326
+ const maxLength = Number(opt1);
1327
+ const suffix = options?.[1] ?? '…';
1328
+ return (value) => {
1329
+ const v = String(value);
1330
+ if (v.length <= maxLength) {
1331
+ return v;
1332
+ }
1333
+ return v.slice(0, maxLength) + suffix;
1334
+ };
1335
+ };
1197
1336
  /**
1198
1337
  * Date filter - formats Date object as localized date string.
1199
1338
  *
@@ -1257,6 +1396,27 @@ const ymd = (options) => {
1257
1396
  return `${year}${opt}${month}${opt}${day}`;
1258
1397
  };
1259
1398
  };
1399
+ /**
1400
+ * Hour-Minute-Second filter - formats Date object as HH:MM:SS string.
1401
+ *
1402
+ * The counterpart of `ymd`: a fixed, zero-padded, locale-independent rendering with a
1403
+ * configurable separator, for when `time` (locale-formatted) is not stable enough.
1404
+ *
1405
+ * @param options - Array with separator string as first element (default: ':')
1406
+ * @returns Filter function that returns formatted time string
1407
+ */
1408
+ const hms = (options) => {
1409
+ const opt = options?.[0] ?? ':';
1410
+ return (value) => {
1411
+ if (!(value instanceof Date)) {
1412
+ valueMustBeDate('hms');
1413
+ }
1414
+ const hours = value.getHours().toString().padStart(2, '0');
1415
+ const minutes = value.getMinutes().toString().padStart(2, '0');
1416
+ const seconds = value.getSeconds().toString().padStart(2, '0');
1417
+ return `${hours}${opt}${minutes}${opt}${seconds}`;
1418
+ };
1419
+ };
1260
1420
  /**
1261
1421
  * Falsy filter - checks if value is falsy.
1262
1422
  *
@@ -1347,6 +1507,8 @@ const builtinFilters = {
1347
1507
  "mul": mul,
1348
1508
  "div": div,
1349
1509
  "mod": mod,
1510
+ "abs": abs,
1511
+ "clamp": clamp,
1350
1512
  "fix": fix,
1351
1513
  "locale": locale,
1352
1514
  "uc": uc,
@@ -1358,16 +1520,20 @@ const builtinFilters = {
1358
1520
  "pad": pad,
1359
1521
  "rep": rep,
1360
1522
  "rev": rev,
1523
+ "truncate": truncate,
1524
+ "join": join,
1361
1525
  "int": int,
1362
1526
  "float": float,
1363
1527
  "round": round,
1364
1528
  "floor": floor,
1365
1529
  "ceil": ceil,
1366
1530
  "percent": percent,
1531
+ "unit": unit,
1367
1532
  "date": date,
1368
1533
  "time": time,
1369
1534
  "datetime": datetime,
1370
1535
  "ymd": ymd,
1536
+ "hms": hms,
1371
1537
  "falsy": falsy,
1372
1538
  "truthy": truthy,
1373
1539
  "defaults": defaults,
@@ -1397,11 +1563,44 @@ const builtinFilterFn = (name, options) => (filters) => {
1397
1563
  return filter(options);
1398
1564
  };
1399
1565
 
1566
+ /**
1567
+ * フィルタ引数リストのパース。`filter(a, b)` の `a, b` 部分を受け取る。
1568
+ *
1569
+ * トリムの規則は「**クォートの外側だけ**」。`fix( 2 )` のような書き癖を吸収するために
1570
+ * 素の引数は前後をトリムするが、クォートは「ここは literal」という宣言なので中身の
1571
+ * 空白は残す。両方まとめてトリムしていたため `pad(5, ' ')` が空文字パディング
1572
+ * (=無変化)に化けており、空白区切りの `join(' / ')` も指定できなかった。
1573
+ */
1574
+ /** 引数 1 つを確定する。クォート由来の文字が入った範囲より外側だけをトリムする。 */
1575
+ function finalizeArg(text, firstQuoteStart, lastQuoteEnd) {
1576
+ // 先頭側: 最初のクォート文字より前だけが削れる(クォートが無ければ全体が対象)
1577
+ const startLimit = firstQuoteStart === -1 ? text.length : firstQuoteStart;
1578
+ let start = 0;
1579
+ while (start < startLimit && /\s/.test(text[start])) {
1580
+ start++;
1581
+ }
1582
+ // 末尾側: 最後のクォート文字より後ろだけが削れる(クォートが無ければ全体が対象)
1583
+ const endLimit = lastQuoteEnd === -1 ? 0 : lastQuoteEnd;
1584
+ let end = text.length;
1585
+ while (end > endLimit && /\s/.test(text[end - 1])) {
1586
+ end--;
1587
+ }
1588
+ return text.slice(start, end);
1589
+ }
1400
1590
  function parseFilterArgs(argsText) {
1401
1591
  const args = [];
1402
1592
  let current = '';
1403
1593
  let inQuote = null;
1404
1594
  let hasQuote = false;
1595
+ let firstQuoteStart = -1;
1596
+ let lastQuoteEnd = -1;
1597
+ const flush = () => {
1598
+ args.push(finalizeArg(current, firstQuoteStart, lastQuoteEnd));
1599
+ current = '';
1600
+ hasQuote = false;
1601
+ firstQuoteStart = -1;
1602
+ lastQuoteEnd = -1;
1603
+ };
1405
1604
  for (let i = 0; i < argsText.length; i++) {
1406
1605
  const char = argsText[i];
1407
1606
  if (inQuote) {
@@ -1409,7 +1608,11 @@ function parseFilterArgs(argsText) {
1409
1608
  inQuote = null;
1410
1609
  }
1411
1610
  else {
1611
+ if (firstQuoteStart === -1) {
1612
+ firstQuoteStart = current.length;
1613
+ }
1412
1614
  current += char;
1615
+ lastQuoteEnd = current.length;
1413
1616
  }
1414
1617
  }
1415
1618
  else if (char === '"' || char === "'") {
@@ -1417,15 +1620,13 @@ function parseFilterArgs(argsText) {
1417
1620
  hasQuote = true;
1418
1621
  }
1419
1622
  else if (char === ',') {
1420
- args.push(current.trim());
1421
- current = '';
1422
- hasQuote = false;
1623
+ flush();
1423
1624
  }
1424
1625
  else {
1425
1626
  current += char;
1426
1627
  }
1427
1628
  }
1428
- const last = current.trim();
1629
+ const last = finalizeArg(current, firstQuoteStart, lastQuoteEnd);
1429
1630
  if (last || hasQuote) {
1430
1631
  args.push(last);
1431
1632
  }
@@ -1778,13 +1979,97 @@ function getParseBindTextResults(node) {
1778
1979
  return [];
1779
1980
  }
1780
1981
 
1982
+ /**
1983
+ * bindings/lightDomComponentScope.ts — Light DOM の mapped `bind-component` を
1984
+ * 「ホストとは別のバインディングスコープ」として扱うための判定
1985
+ * (docs/architecture-hardening/15-state-component-mechanism-consistency.md §1.13)。
1986
+ *
1987
+ * Shadow DOM 形では、コンポーネントの `<wcs-state>` が**別 rootNode** にいることで
1988
+ * 2 つのことが同時に成立している。
1989
+ *
1990
+ * 1. ホスト root の `waitForStateInitialize` の走査集合に入らない
1991
+ * 2. 子スコープのバインディングがホストとは別の `buildBindings` パスで処理される
1992
+ *
1993
+ * Light DOM では両方が失われる。1 が失われると、
1994
+ * 「ホストの `waitForStateInitialize` が子 state を待つ →
1995
+ * 子 state は自分を束ねるホスト binding を待つ →
1996
+ * その binding を作る `initializeBindings` は `waitForStateInitialize` の後」
1997
+ * という循環になり、初期化が永久に解決しない。2 が失われると、子スコープの
1998
+ * `@name` 参照がホストと同じパスで解決されてしまい、子 state の名前登録より
1999
+ * 先に評価される。
2000
+ *
2001
+ * このモジュールはその 2 つを明示的に復元するための判定だけを持つ。
2002
+ *
2003
+ * **plain(ホストからバインドしない state 注入)は対象外**であることに注意。
2004
+ * plain は `waitInitializeBinding` を通らないので循環せず、従来どおりホストと
2005
+ * 同じパスで初期化して問題ない。ここで一律に切り出すと、成立している plain 形が
2006
+ * 「子 state の登録前に `@name` を解決する」形に退行する。
2007
+ */
2008
+ /** `<wcs-state bind-component>` が Light DOM の mapped 形(=別スコープ扱い)か。 */
2009
+ function isLightDomMappedStateElement(stateElement) {
2010
+ if (!stateElement.hasAttribute("bind-component")) {
2011
+ return false;
2012
+ }
2013
+ const parentNode = stateElement.parentNode;
2014
+ // Shadow DOM 形では parentNode が ShadowRoot になる(かつホスト root の
2015
+ // querySelectorAll にはそもそも出てこない)
2016
+ if (!(parentNode instanceof Element)) {
2017
+ return false;
2018
+ }
2019
+ // ホストからバインドされていなければ plain。従来どおりの扱いに任せる
2020
+ return parentNode.hasAttribute(config.bindAttributeName);
2021
+ }
2022
+ /**
2023
+ * `root` の内側にある Light DOM mapped コンポーネント要素を集める。
2024
+ *
2025
+ * `root` 自身は**含めない**。子スコープが自分のパスとして
2026
+ * `initializeBindings(componentElement)` を呼ぶとき、その要素自身まで prune すると
2027
+ * 何も初期化されなくなるため。
2028
+ */
2029
+ function findNestedLightDomComponents(root) {
2030
+ const components = [];
2031
+ const stateElements = root.querySelectorAll(`${config.tagNames.state}[bind-component]`);
2032
+ for (const stateElement of stateElements) {
2033
+ if (!isLightDomMappedStateElement(stateElement)) {
2034
+ continue;
2035
+ }
2036
+ const component = stateElement.parentNode;
2037
+ if (component === root) {
2038
+ continue;
2039
+ }
2040
+ components.push(component);
2041
+ }
2042
+ return components;
2043
+ }
2044
+ /** `node` が、いずれかのコンポーネント要素の**真の**子孫か。 */
2045
+ function isInsideAnyComponent(node, components) {
2046
+ for (let i = 0; i < components.length; i++) {
2047
+ const component = components[i];
2048
+ if (component !== node && component.contains(node)) {
2049
+ return true;
2050
+ }
2051
+ }
2052
+ return false;
2053
+ }
2054
+
1781
2055
  /**
1782
2056
  * data-wcs 属性または埋め込みノード<!--{{}}-->を持つノードをすべて取得する
2057
+ *
2058
+ * Light DOM の mapped コンポーネントの**内側**は除外する(§1.13)。そのサブツリーの
2059
+ * `@name` 参照は、コンポーネント側の state が名前登録を済ませてからでないと解決できず、
2060
+ * ホストと同じパスで拾うと登録前に評価されてしまう。除外したぶんは、その state が
2061
+ * 初期化を終えた時点で自分のスコープとして `initializeBindings(componentElement)` を
2062
+ * 呼び直す(Shadow DOM 形で rootNode ごとにパスが分かれるのと同じ形にする)。
2063
+ *
2064
+ * コンポーネント要素**自身**は除外しない。ホスト側の `data-wcs`(`state.msg: user.name`)
2065
+ * はホストのスコープに属し、それが張られることで子側の待ちが解ける。
2066
+ *
1783
2067
  * @param root
1784
2068
  * @returns
1785
2069
  */
1786
2070
  function getSubscriberNodes(root) {
1787
2071
  const subscriberNodes = [];
2072
+ const nestedComponents = findNestedLightDomComponents(root);
1788
2073
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT, {
1789
2074
  acceptNode(node) {
1790
2075
  if (node.nodeType === Node.ELEMENT_NODE) {
@@ -1803,7 +2088,14 @@ function getSubscriberNodes(root) {
1803
2088
  }
1804
2089
  });
1805
2090
  while (walker.nextNode()) {
1806
- subscriberNodes.push(walker.currentNode);
2091
+ const node = walker.currentNode;
2092
+ // TreeWalker の acceptNode は「自分は拾うが子孫は辿らない」を表現できないため、
2093
+ // コンポーネント要素自身を拾ったうえで、その子孫をここで落とす。
2094
+ // nestedComponents が空(=圧倒的多数)のときは contains 走査ごと発生しない。
2095
+ if (nestedComponents.length > 0 && isInsideAnyComponent(node, nestedComponents)) {
2096
+ continue;
2097
+ }
2098
+ subscriberNodes.push(node);
1807
2099
  }
1808
2100
  return subscriberNodes;
1809
2101
  }
@@ -2642,12 +2934,12 @@ class EventToken extends Token {
2642
2934
  }
2643
2935
  }
2644
2936
 
2645
- const registryByStateElement$2 = new WeakMap();
2937
+ const registryByStateElement$3 = new WeakMap();
2646
2938
  function getOrCreateEventToken(stateElement, name) {
2647
- let registry = registryByStateElement$2.get(stateElement);
2939
+ let registry = registryByStateElement$3.get(stateElement);
2648
2940
  if (typeof registry === "undefined") {
2649
2941
  registry = new Map();
2650
- registryByStateElement$2.set(stateElement, registry);
2942
+ registryByStateElement$3.set(stateElement, registry);
2651
2943
  }
2652
2944
  let token = registry.get(name);
2653
2945
  if (typeof token === "undefined") {
@@ -2657,7 +2949,7 @@ function getOrCreateEventToken(stateElement, name) {
2657
2949
  return token;
2658
2950
  }
2659
2951
  function clearEventTokenRegistry(stateElement) {
2660
- registryByStateElement$2.delete(stateElement);
2952
+ registryByStateElement$3.delete(stateElement);
2661
2953
  }
2662
2954
 
2663
2955
  /**
@@ -3401,6 +3693,42 @@ function detachTwowayEventHandler(binding) {
3401
3693
  }
3402
3694
  }
3403
3695
 
3696
+ const stateElementByWebComponent = new WeakMap();
3697
+ function setStateElementByWebComponent(webComponent, stateName, stateElement) {
3698
+ let stateMap = stateElementByWebComponent.get(webComponent);
3699
+ if (!stateMap) {
3700
+ stateMap = new Map();
3701
+ stateElementByWebComponent.set(webComponent, stateMap);
3702
+ }
3703
+ stateMap.set(stateName, stateElement);
3704
+ }
3705
+ function getStateElementByWebComponent(webComponent, stateName) {
3706
+ const stateMap = stateElementByWebComponent.get(webComponent);
3707
+ if (!stateMap) {
3708
+ return null;
3709
+ }
3710
+ return stateMap.get(stateName) ?? null;
3711
+ }
3712
+ /**
3713
+ * コンポーネントが mapped されている「1 つ外のスコープ」の state 要素。
3714
+ * `buildPrimaryMappingRule` がプライマリ規則から記録する
3715
+ * (規則の outer 側が属する state 要素 = 値の正本を持つスコープそのもの)。
3716
+ *
3717
+ * 用途は Δ(base listIndex)の境界越え合成(§1.12)。`getLoopContextByNode` は
3718
+ * `parentNode` しか辿らず shadow 境界を越えないため、Δ を外へ引き継ぐには
3719
+ * 「1 つ外のスコープ」への明示的なリンクが要る。
3720
+ *
3721
+ * この台帳を MappingRule ではなくここに置くのは循環参照を避けるため
3722
+ * (baseListIndex → MappingRule → BindingSession → outerListPath → baseListIndex)。
3723
+ */
3724
+ const outerStateElementByWebComponent = new WeakMap();
3725
+ function setOuterStateElementByWebComponent(webComponent, stateElement) {
3726
+ outerStateElementByWebComponent.set(webComponent, stateElement);
3727
+ }
3728
+ function getOuterStateElementByWebComponent(webComponent) {
3729
+ return outerStateElementByWebComponent.get(webComponent) ?? null;
3730
+ }
3731
+
3404
3732
  /**
3405
3733
  * webComponent/baseListIndex.ts
3406
3734
  *
@@ -3423,19 +3751,49 @@ function detachTwowayEventHandler(binding) {
3423
3751
  * ホットパスに walk は載らない。
3424
3752
  */
3425
3753
  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;
3754
+ let current = stateElement;
3755
+ for (;;) {
3756
+ if (current == null || current.hasMappedComponentState !== true) {
3757
+ return null;
3758
+ }
3759
+ const component = current.boundComponent;
3760
+ if (component == null) {
3761
+ return null;
3762
+ }
3763
+ const listIndex = getLoopContextByNode(component)?.listIndex;
3764
+ if (listIndex != null) {
3765
+ return listIndex;
3766
+ }
3767
+ // このスコープには囲むループが無い。コンポーネントがさらに別の mapped な
3768
+ // コンポーネントの shadow の中にいるなら、Δ は外側スコープから引き継ぐ(§1.12)。
3769
+ //
3770
+ // `getLoopContextByNode` は `parentNode` しか辿らず、ShadowRoot の parentNode は
3771
+ // null なので shadow 境界で必ず止まる。境界 1 枚なら外側は素の文書スコープで
3772
+ // Δ=0 が正しいが、2 枚重なっていると中間スコープの Δ が丸ごと落ちて
3773
+ // 子の listIndex が正本スコープより浅い arity で作られる。
3774
+ //
3775
+ // 外側が mapped でない(=値の正本がそのスコープにある)なら、そこから先の
3776
+ // ループはこの子のリストとは無関係なので次の周回の先頭ガードで止まる。
3777
+ current = getOuterStateElementByWebComponent(component);
3432
3778
  }
3433
- return getLoopContextByNode(component)?.listIndex ?? null;
3434
3779
  }
3435
3780
  /** base の段数 Δ。base が無ければ 0。 */
3436
3781
  function getBaseDepth(stateElement) {
3437
3782
  return getBaseListIndex(stateElement)?.length ?? 0;
3438
3783
  }
3784
+ /**
3785
+ * そのスコープでそのパスに実際に使われる listIndex の arity。
3786
+ *
3787
+ * パス自身のワイルドカード段数に、そのスコープが外側のループの内側にいる分(Δ)を
3788
+ * 足したもの。`items.*` は子スコープから見れば 1 段でも、そのスコープが Δ=1 の位置に
3789
+ * あれば台帳の listIndex は arity 2 になる(§1.10)。
3790
+ *
3791
+ * **境界を跨ぐ照合はこの実 arity どうしで行うこと**(§1.12)。片側だけ Δ を足すと、
3792
+ * 境界が 2 枚以上あるときに中間スコープの Δ を二重計上して不一致になる。
3793
+ */
3794
+ function getScopeArity(stateElement, pathInfo) {
3795
+ return pathInfo.wildcardCount + getBaseDepth(stateElement);
3796
+ }
3439
3797
  /**
3440
3798
  * リストの行を生成するときの親 listIndex。
3441
3799
  *
@@ -3451,23 +3809,6 @@ function getListParentListIndex(stateElement, containerListIndex) {
3451
3809
  return containerListIndex ?? getBaseListIndex(stateElement);
3452
3810
  }
3453
3811
 
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
3812
  const innerMappingByElement = new WeakMap();
3472
3813
  const outerMappingByElement = new WeakMap();
3473
3814
  const primaryMappingRuleSetByElement = new WeakMap();
@@ -3503,6 +3844,10 @@ function buildPrimaryMappingRule(webComponent, stateName, bindings) {
3503
3844
  primaryBindingByMappingRule.set(mappingRule, binding);
3504
3845
  innerMappingRule.set(innerAbsPathInfo, outerAbsPathInfo);
3505
3846
  outerMappingRule.set(outerAbsPathInfo, innerAbsPathInfo);
3847
+ // 1 つ外のスコープへのリンク。Δ の境界越え合成(§1.12)が引く。
3848
+ // プライマリ規則はすべて同じホスト要素の data-wcs 由来なので、どの規則から
3849
+ // 採っても同じスコープを指す。
3850
+ setOuterStateElementByWebComponent(webComponent, outerAbsPathInfo.stateElement);
3506
3851
  }
3507
3852
  innerMappingByElement.set(webComponent, innerMappingRule);
3508
3853
  outerMappingByElement.set(webComponent, outerMappingRule);
@@ -3728,12 +4073,56 @@ function getOuterRowPathInfo(innerStateElement, innerPathInfo) {
3728
4073
  if (innerPathInfo.wildcardCount === 0) {
3729
4074
  return null;
3730
4075
  }
4076
+ return stepOuterRowPathInfo(innerStateElement, innerPathInfo);
4077
+ }
4078
+ /**
4079
+ * `getOuterRowPathInfo` の 2 段目以降。境界が 2 枚以上重なっている(コンポーネントの
4080
+ * shadow の中にさらに mapped な `bind-component` がある)場合、値の正本は 1 つ外では
4081
+ * なく**最も外のスコープ**にある。1 段目だけに相乗りしていると、中間スコープは
4082
+ * 素通しで自分の行バインディングを持たないため、正本スコープ起点の行フィールド
4083
+ * 書き込みを購読する者が誰もいなくなる(§1.11)。
4084
+ *
4085
+ * 1 段目が成立したときだけ呼ばれる(=mapped な行バインディング限定)ので、
4086
+ * 通常のリストはこの walk を一切踏まない。返り値は 2 段目以降が無ければ `null` で、
4087
+ * 圧倒的多数である深さ 1 の行では配列を確保しない。
4088
+ *
4089
+ * 各段で必ず外側の state 要素へ進む(`resolveOuterAbsolutePathInfo` は
4090
+ * `boundComponent` の属するスコープを返す=DOM 上の真の祖先)ので停止する。
4091
+ * `propagateListPathToOuterState` の外向き伝播と同じ論拠。
4092
+ */
4093
+ function getOuterRowPathInfosBeyond(firstOuterAbsPathInfo) {
4094
+ let rest = null;
4095
+ let stateElement = firstOuterAbsPathInfo.stateElement;
4096
+ let pathInfo = firstOuterAbsPathInfo.pathInfo;
4097
+ for (;;) {
4098
+ const outerAbsPathInfo = stepOuterRowPathInfo(stateElement, pathInfo);
4099
+ if (outerAbsPathInfo === null) {
4100
+ return rest;
4101
+ }
4102
+ (rest ??= []).push(outerAbsPathInfo);
4103
+ stateElement = outerAbsPathInfo.stateElement;
4104
+ pathInfo = outerAbsPathInfo.pathInfo;
4105
+ }
4106
+ }
4107
+ /**
4108
+ * 境界 1 枚分の外向き解決。成立条件の判定を含む。
4109
+ *
4110
+ * 判定は**両側の実 arity(`getScopeArity` = パスの段数 + そのスコープの Δ)が
4111
+ * 一致すること**。相乗り登録は子の listIndex をそのまま鍵に使うので、外側スコープが
4112
+ * その arity で台帳を引けなければ意味がない。
4113
+ *
4114
+ * 境界 1 枚なら外側は Δ=0 なので、これは従来の `Δ + innerW === outerW` と同値。
4115
+ * 2 枚以上あるときに外側の Δ を数えないと、中間スコープの Δ を二重計上して
4116
+ * 成立するはずの段を落とす(§1.12)。
4117
+ */
4118
+ function stepOuterRowPathInfo(innerStateElement, innerPathInfo) {
3731
4119
  const outerAbsPathInfo = resolveOuterAbsolutePathInfo(innerStateElement, innerPathInfo);
3732
4120
  if (outerAbsPathInfo === null || outerAbsPathInfo.stateElement === innerStateElement) {
3733
4121
  return null;
3734
4122
  }
3735
- const baseDepth = getBaseDepth(innerStateElement);
3736
- if (outerAbsPathInfo.pathInfo.wildcardCount !== innerPathInfo.wildcardCount + baseDepth) {
4123
+ const innerArity = getScopeArity(innerStateElement, innerPathInfo);
4124
+ const outerArity = getScopeArity(outerAbsPathInfo.stateElement, outerAbsPathInfo.pathInfo);
4125
+ if (innerArity !== outerArity) {
3737
4126
  return null;
3738
4127
  }
3739
4128
  return outerAbsPathInfo;
@@ -4552,6 +4941,7 @@ class BindingSession {
4552
4941
  patternPathInfo: null,
4553
4942
  patternListIndex: null,
4554
4943
  outerPatternPathInfo: null,
4944
+ outerPatternPathInfosRest: null,
4555
4945
  pendingDefinitions: 0,
4556
4946
  initialPolicy: slot.policy,
4557
4947
  resolvedAuthority: slot.authority,
@@ -4668,6 +5058,7 @@ class BindingSession {
4668
5058
  patternPathInfo: null,
4669
5059
  patternListIndex: null,
4670
5060
  outerPatternPathInfo: null,
5061
+ outerPatternPathInfosRest: null,
4671
5062
  pendingDefinitions: 0,
4672
5063
  initialPolicy: null,
4673
5064
  resolvedAuthority: null,
@@ -4884,6 +5275,17 @@ class BindingSession {
4884
5275
  if (outerPathInfo !== null) {
4885
5276
  addBindingByPattern(outerPathInfo, listIndex, binding);
4886
5277
  record.outerPatternPathInfo = outerPathInfo;
5278
+ // 境界が 2 枚以上重なっていると、値の正本は 1 つ外ではなく最も外のスコープに
5279
+ // ある。中間スコープは配列を素通しするだけで自分の行バインディングを持たない
5280
+ // ため、1 段目だけでは正本スコープ起点の行フィールド書き込みが誰にも届かない
5281
+ // (§1.11)。成立する段すべてに載せる。
5282
+ const restPathInfos = getOuterRowPathInfosBeyond(outerPathInfo);
5283
+ if (restPathInfos !== null) {
5284
+ for (let i = 0; i < restPathInfos.length; i++) {
5285
+ addBindingByPattern(restPathInfos[i], listIndex, binding);
5286
+ }
5287
+ record.outerPatternPathInfosRest = restPathInfos;
5288
+ }
4887
5289
  }
4888
5290
  }
4889
5291
  else {
@@ -4951,6 +5353,19 @@ class BindingSession {
4951
5353
  }
4952
5354
  record.outerPatternPathInfo = null;
4953
5355
  }
5356
+ // 3 段目以降(§1.11)。各段も互いに独立した資源なので 1 つずつ守る
5357
+ if (record.outerPatternPathInfosRest !== null) {
5358
+ const restPathInfos = record.outerPatternPathInfosRest;
5359
+ for (let i = 0; i < restPathInfos.length; i++) {
5360
+ try {
5361
+ removeBindingByPattern(restPathInfos[i], record.patternListIndex, binding);
5362
+ }
5363
+ catch {
5364
+ // Cleanup is best-effort.
5365
+ }
5366
+ }
5367
+ record.outerPatternPathInfosRest = null;
5368
+ }
4954
5369
  try {
4955
5370
  removeBindingByPattern(record.patternPathInfo, record.patternListIndex, binding);
4956
5371
  record.patternPathInfo = null;
@@ -7453,9 +7868,17 @@ function _getFragmentInfo(rootNode, fragment, parseBindingTextResult, forPath) {
7453
7868
  }
7454
7869
  function collectStructuralFragments(rootNode, walkRoot, forPath) {
7455
7870
  const elseKeyword = config.commentElsePrefix;
7871
+ // Light DOM の mapped コンポーネントの内側は、その子スコープが自分で処理する(§1.13)。
7872
+ // fragment info は rootNode + state 名で登録されるため、ホストのパスでここを拾うと
7873
+ // コンポーネント側の state がまだ名前登録を済ませておらず解決に失敗する。
7874
+ // コンポーネント要素自身は template ではないので、REJECT でサブツリーごと落として問題ない。
7875
+ const nestedComponents = findNestedLightDomComponents(walkRoot);
7456
7876
  const walker = document.createTreeWalker(walkRoot, NodeFilter.SHOW_ELEMENT, {
7457
7877
  acceptNode(node) {
7458
7878
  const element = node;
7879
+ if (nestedComponents.length > 0 && nestedComponents.indexOf(element) !== -1) {
7880
+ return NodeFilter.FILTER_REJECT;
7881
+ }
7459
7882
  if (element.tagName.toLowerCase() === 'template') {
7460
7883
  const bindText = element.getAttribute(config.bindAttributeName) || '';
7461
7884
  if (bindText.length > 0) {
@@ -7576,6 +7999,13 @@ async function waitForStateInitialize(root) {
7576
7999
  const promises = [];
7577
8000
  await customElements.whenDefined(config.tagNames.state);
7578
8001
  for (const element of elements) {
8002
+ // Light DOM の mapped コンポーネントの state は待たない。それはこの root の
8003
+ // バインディングが張られてからでないと初期化できず(自分を束ねるホスト binding を
8004
+ // 待つ)、ここで待つと循環する(§1.13)。Shadow DOM 形では別 rootNode にいるので
8005
+ // そもそもこの集合に現れず、plain 形は循環しないので従来どおり待つ。
8006
+ if (isLightDomMappedStateElement(element)) {
8007
+ continue;
8008
+ }
7579
8009
  const stateElement = element;
7580
8010
  promises.push(stateElement.initializePromise);
7581
8011
  }
@@ -7606,7 +8036,7 @@ async function buildBindings(root) {
7606
8036
  }
7607
8037
  }
7608
8038
 
7609
- var version = "1.26.0";
8039
+ var version = "1.27.0";
7610
8040
  var pkg = {
7611
8041
  version: version};
7612
8042
 
@@ -8441,27 +8871,89 @@ function setStateElementByName(rootNode, name, element) {
8441
8871
  }
8442
8872
  }
8443
8873
 
8444
- const updateBatchListeners = new Set();
8874
+ /**
8875
+ * watch/chainDepth.ts
8876
+ *
8877
+ * `$watch` ハンドラ起点の書き込み連鎖の深さを数える台帳
8878
+ * (docs/state-watch-hook-design.md §7-2)。
8879
+ *
8880
+ * watch ハンドラ内の書き込みは新しい microtask バッチを作るため、伝播 context の
8881
+ * hop 上限(MAX_PROPAGATION_HOPS)のガードが効かない。かつ書き込み先が動的なので、
8882
+ * `$streams` のような「宣言時の自己依存検出」も使えない。よって実行時に数える。
8883
+ *
8884
+ * updater(enqueue 側)と watchRuntime(発火側)の両方から参照されるため、
8885
+ * **依存ゼロの葉モジュール**にして循環 import を避ける(devtools/sink.ts と同じ方針)。
8886
+ *
8887
+ * 数え方: ハンドラ実行中に enqueue が起きたときだけ「次のバッチはこの連鎖の続き」と
8888
+ * マークする。ハンドラが何も書かなければ次のバッチは深さ 0 に戻るので、利用者操作が
8889
+ * 何度続いても深さは伸びない。
8890
+ */
8891
+ /** ハンドラ実行中に立つ「今の連鎖の深さ + 1」。0 なら watch 起点ではない */
8892
+ let firingDepth = 0;
8893
+ /** 次に drain されるバッチの深さ */
8894
+ let pendingDepth = 0;
8895
+ /** watch の発火フェーズ開始(watchRuntime 専用) */
8896
+ function beginWatchFiring(depth) {
8897
+ firingDepth = depth + 1;
8898
+ }
8899
+ /** watch の発火フェーズ終了(watchRuntime 専用。必ず finally で呼ぶ) */
8900
+ function endWatchFiring() {
8901
+ firingDepth = 0;
8902
+ }
8903
+ /**
8904
+ * 書き込みの enqueue を記録する(updater 専用)。
8905
+ * ハンドラ実行中でなければ何もしない = 通常の書き込みに深さは付かない。
8906
+ */
8907
+ function noteEnqueueForWatchChain() {
8908
+ if (firingDepth > pendingDepth) {
8909
+ pendingDepth = firingDepth;
8910
+ }
8911
+ }
8912
+ /** 次バッチの深さを消費する(watchRuntime 専用。読んだらリセット) */
8913
+ function consumeWatchChainDepth() {
8914
+ const depth = pendingDepth;
8915
+ pendingDepth = 0;
8916
+ return depth;
8917
+ }
8918
+
8919
+ const updateBatchListeners = [];
8445
8920
  /**
8446
8921
  * drain 終了リスナーを登録する。
8922
+ *
8923
+ * `priority` の昇順に呼ばれる(同値は登録順)。機構間の実行順序
8924
+ * (`$watch` → `$streams` restart、docs/state-watch-hook-design.md §3-2 層 1)は
8925
+ * この優先度で固定する — import 順に順序を持たせると、無関係な import 整理で
8926
+ * 静かに壊れるため。定数は define.ts の `*_LISTENER_PRIORITY` を使うこと。
8447
8927
  */
8448
- function registerUpdateBatchListener(listener) {
8449
- updateBatchListeners.add(listener);
8928
+ function registerUpdateBatchListener(listener, priority = 0) {
8929
+ // 挿入ソート: 同値優先度の中では登録順を保つ(find は最初の「より大きい」要素を指す)
8930
+ const index = updateBatchListeners.findIndex((registered) => registered.priority > priority);
8931
+ const entry = { listener, priority };
8932
+ if (index === -1) {
8933
+ updateBatchListeners.push(entry);
8934
+ }
8935
+ else {
8936
+ updateBatchListeners.splice(index, 0, entry);
8937
+ }
8450
8938
  }
8451
8939
  /**
8452
8940
  * drain 終了リスナーを解除する(テスト間の分離用)。
8453
8941
  */
8454
8942
  function unregisterUpdateBatchListener(listener) {
8455
- updateBatchListeners.delete(listener);
8943
+ const index = updateBatchListeners.findIndex((registered) => registered.listener === listener);
8944
+ if (index !== -1) {
8945
+ updateBatchListeners.splice(index, 1);
8946
+ }
8456
8947
  }
8457
8948
  /**
8458
- * 全リスナーに drain のバッチを通知する。
8949
+ * 全リスナーに drain のバッチを優先度順で通知する。
8459
8950
  * リスナーの throw は握りつぶさない(内部バグの隠蔽防止)。
8460
- * stream 側リスナーが entry ごとに自前で try/catch する契約(設計書 §3-2)。
8951
+ * stream / watch 側リスナーが entry ごとに自前で try/catch する契約(設計書 §3-2)。
8461
8952
  */
8462
8953
  function notifyUpdateBatchListeners(batch) {
8463
- for (const listener of updateBatchListeners) {
8464
- listener(batch);
8954
+ // 反復中の register / unregister(ハンドラ内の切断・再 set)に耐えるためコピーする
8955
+ for (const registered of updateBatchListeners.slice()) {
8956
+ registered.listener(batch);
8465
8957
  }
8466
8958
  }
8467
8959
  class Updater {
@@ -8469,6 +8961,9 @@ class Updater {
8469
8961
  constructor() {
8470
8962
  }
8471
8963
  enqueueAbsoluteAddress(absoluteAddress, context = null) {
8964
+ // `$watch` ハンドラ実行中の書き込みだけを連鎖としてマークする(watch/chainDepth.ts)。
8965
+ // ハンドラ実行中でなければ即 return する葉モジュール呼び出し 1 個のコスト。
8966
+ noteEnqueueForWatchChain();
8472
8967
  const requireStartProcess = this._queueUpdateRecords.length === 0;
8473
8968
  this._queueUpdateRecords.push({ absoluteAddress, context });
8474
8969
  if (requireStartProcess) {
@@ -8695,7 +9190,9 @@ function setSink(sink) {
8695
9190
  setDevtoolsSink(sink);
8696
9191
  const isActive = sink !== null;
8697
9192
  if (isActive && !wasActive) {
8698
- registerUpdateBatchListener(onUpdateBatch);
9193
+ // `$watch` / `$streams` restart より先に流す(protocol §4.3)。優先度を省略しても
9194
+ // 既定 0 で結果は同じだが、それは偶然なので定数で意図を固定する。
9195
+ registerUpdateBatchListener(onUpdateBatch, DEVTOOLS_LISTENER_PRIORITY);
8699
9196
  }
8700
9197
  else if (!isActive && wasActive) {
8701
9198
  unregisterUpdateBatchListener(onUpdateBatch);
@@ -9012,12 +9509,12 @@ function processCommandTokensDeclaration(state) {
9012
9509
  return names;
9013
9510
  }
9014
9511
 
9015
- const registryByStateElement$1 = new WeakMap();
9512
+ const registryByStateElement$2 = new WeakMap();
9016
9513
  function getOrCreateCommandToken(stateElement, name) {
9017
- let registry = registryByStateElement$1.get(stateElement);
9514
+ let registry = registryByStateElement$2.get(stateElement);
9018
9515
  if (typeof registry === "undefined") {
9019
9516
  registry = new Map();
9020
- registryByStateElement$1.set(stateElement, registry);
9517
+ registryByStateElement$2.set(stateElement, registry);
9021
9518
  }
9022
9519
  let token = registry.get(name);
9023
9520
  if (typeof token === "undefined") {
@@ -9027,7 +9524,7 @@ function getOrCreateCommandToken(stateElement, name) {
9027
9524
  return token;
9028
9525
  }
9029
9526
  function clearCommandTokenRegistry(stateElement) {
9030
- registryByStateElement$1.delete(stateElement);
9527
+ registryByStateElement$2.delete(stateElement);
9031
9528
  }
9032
9529
 
9033
9530
  /**
@@ -9262,24 +9759,24 @@ function invalidateLastNotified(stateElement, name) {
9262
9759
  * 設計書 §3-2 の「未接続(disconnect 済み)の stateElement の entry は restart
9263
9760
  * しない」はこの不変条件で担保される。
9264
9761
  */
9265
- const activeStateElements = new Set();
9762
+ const activeStateElements$1 = new Set();
9266
9763
  /**
9267
9764
  * 起動中 stateElement として登録する(startStreams 専用。不変条件はモジュールヘッダ参照)。
9268
9765
  */
9269
9766
  function addActiveStateElement(stateElement) {
9270
- activeStateElements.add(stateElement);
9767
+ activeStateElements$1.add(stateElement);
9271
9768
  }
9272
9769
  /**
9273
9770
  * 起動中 stateElement から外す(abortAllStreams / clearStreamRegistry 専用)。
9274
9771
  */
9275
9772
  function deleteActiveStateElement(stateElement) {
9276
- activeStateElements.delete(stateElement);
9773
+ activeStateElements$1.delete(stateElement);
9277
9774
  }
9278
9775
  /**
9279
9776
  * 起動中 stateElement を列挙する(drain リスナーの交差判定用)。
9280
9777
  */
9281
9778
  function getActiveStateElements() {
9282
- return activeStateElements;
9779
+ return activeStateElements$1;
9283
9780
  }
9284
9781
 
9285
9782
  /**
@@ -9292,18 +9789,18 @@ function getActiveStateElements() {
9292
9789
  * - disconnect 時は abortAllStreams(abort のみ・registry 保持)、
9293
9790
  * `_state` 再 set 時のみ clearStreamRegistry(abort + 全削除)。
9294
9791
  */
9295
- const registryByStateElement = new WeakMap();
9792
+ const registryByStateElement$1 = new WeakMap();
9296
9793
  /**
9297
9794
  * stream entry 群を置換登録する(`_state` セッターからの再構築で丸ごと差し替える)。
9298
9795
  */
9299
9796
  function setStreamEntries(stateElement, entries) {
9300
- registryByStateElement.set(stateElement, entries);
9797
+ registryByStateElement$1.set(stateElement, entries);
9301
9798
  }
9302
9799
  /**
9303
9800
  * 登録済みの stream entry 群を返す。未登録なら空 Map を返す(registry への登録はしない)。
9304
9801
  */
9305
9802
  function getStreamEntries(stateElement) {
9306
- return registryByStateElement.get(stateElement) ?? new Map();
9803
+ return registryByStateElement$1.get(stateElement) ?? new Map();
9307
9804
  }
9308
9805
  /**
9309
9806
  * 全 stream を abort して idle に戻す(設計書 §5-1)。registry は保持する。
@@ -9323,7 +9820,7 @@ function abortAllStreams(stateElement) {
9323
9820
  // 設計書 §3-2。add 側は startStreams — stream/activeStateElements.ts の
9324
9821
  // リーク防止不変条件を参照)。registry の有無に関わらず必ず外す。
9325
9822
  deleteActiveStateElement(stateElement);
9326
- const entries = registryByStateElement.get(stateElement);
9823
+ const entries = registryByStateElement$1.get(stateElement);
9327
9824
  if (typeof entries === "undefined") {
9328
9825
  return;
9329
9826
  }
@@ -9343,7 +9840,7 @@ function clearStreamRegistry(stateElement) {
9343
9840
  // abortAllStreams が既に delete 済みだが、「clear = 全削除でも必ず restart 対象から
9344
9841
  // 外れる」不変条件を将来の abortAllStreams の変更から独立に保証するため明示的に呼ぶ。
9345
9842
  deleteActiveStateElement(stateElement);
9346
- registryByStateElement.delete(stateElement);
9843
+ registryByStateElement$1.delete(stateElement);
9347
9844
  }
9348
9845
 
9349
9846
  /**
@@ -10069,7 +10566,501 @@ function restartStreamsOnUpdateBatch(batch) {
10069
10566
  }
10070
10567
  }
10071
10568
  }
10072
- registerUpdateBatchListener(restartStreamsOnUpdateBatch);
10569
+ // 優先度で `$watch` の後に固定する(設計書 §3-2 層 1)。import 順には依存しない。
10570
+ registerUpdateBatchListener(restartStreamsOnUpdateBatch, STREAM_LISTENER_PRIORITY);
10571
+
10572
+ /**
10573
+ * watch/watchRegistry.ts
10574
+ *
10575
+ * `$watch` の registry と、drain リスナーの走査元になる「発火対象の stateElement 集合」
10576
+ * (docs/state-watch-hook-design.md §9)。
10577
+ *
10578
+ * `$streams` は registry(delete 側)と runtime(add 側)が相互に依存するため
10579
+ * active 集合を stream/activeStateElements.ts へ切り出しているが、`$watch` は
10580
+ * **add が State のライフサイクル側、delete が registry 側**で、runtime は読むだけの
10581
+ * 一方向依存になる。よって循環せず、1 モジュールにまとめられる。
10582
+ *
10583
+ * リーク防止の不変条件(strong Set が切断済み要素の GC を妨げないための連動):
10584
+ * - add は `startWatch`(`State.connectedCallback` の $connectedCallback 完了後、および
10585
+ * 接続中の `_state` 再 set)だけが行い、**宣言が 1 つも無い stateElement は入れない**。
10586
+ * - delete は `deactivateWatch`(disconnectedCallback)/`clearWatchRegistry`(`_state`
10587
+ * 再 set)だけが行う。
10588
+ * どちらの経路も必ずここを通るため「Set に居る = 接続中かつ宣言済み」が保たれる。
10589
+ * この「宣言済み」の側が崩れると、`$watch` 未使用アプリの drain にも収集ループが乗る
10590
+ * (ゼロコスト契約、docs/state-watch-hook-design.md §10)。
10591
+ */
10592
+ const registryByStateElement = new WeakMap();
10593
+ const activeStateElements = new Set();
10594
+ /**
10595
+ * 未登録時に返す共有の空 Map。
10596
+ *
10597
+ * ここで毎回 `new Map()` すると、drain の収集ループが「バッチのアドレス 1 個につき
10598
+ * Map を 1 個」アロケートすることになる(発火対象だが宣言を持たない stateElement を
10599
+ * 通る経路)。読み出ししかしない返り値なので 1 個を使い回す。
10600
+ */
10601
+ const EMPTY_ENTRIES = new Map();
10602
+ /**
10603
+ * watch entry 群を置換登録する(`_state` セッターからの再構築で丸ごと差し替える)。
10604
+ */
10605
+ function setWatchEntries(stateElement, entries) {
10606
+ registryByStateElement.set(stateElement, entries);
10607
+ }
10608
+ /**
10609
+ * 登録済みの watch entry 群を返す。未登録なら共有の空 Map を返す
10610
+ * (registry への登録はしない。返り値は読み出し専用)。
10611
+ */
10612
+ function getWatchEntries(stateElement) {
10613
+ return registryByStateElement.get(stateElement) ?? EMPTY_ENTRIES;
10614
+ }
10615
+ /**
10616
+ * 発火対象として登録する(`startWatch` 専用。不変条件はモジュールヘッダ参照)。
10617
+ */
10618
+ function addActiveWatchStateElement(stateElement) {
10619
+ activeStateElements.add(stateElement);
10620
+ }
10621
+ /**
10622
+ * 発火対象を列挙する(drain リスナーの early return 判定用)。
10623
+ */
10624
+ function getActiveWatchStateElements() {
10625
+ return activeStateElements;
10626
+ }
10627
+ /**
10628
+ * 発火対象から外す(切断時)。**registry は保持する。**
10629
+ *
10630
+ * `$streams` の abortAllStreams と同じ二段構えで、切断は「発火しなくなる」だけにする。
10631
+ * registry まで捨てると、再接続(connectedCallback → startWatch)で宣言を作り直す経路が
10632
+ * 無い(`_state` セッターは初回ロード時にしか走らない)ため、watch が二度と発火しない。
10633
+ */
10634
+ function deactivateWatch(stateElement) {
10635
+ activeStateElements.delete(stateElement);
10636
+ }
10637
+ /**
10638
+ * registry から削除し、発火対象からも外す(`_state` 再 set 時の再配線用)。
10639
+ */
10640
+ function clearWatchRegistry(stateElement) {
10641
+ activeStateElements.delete(stateElement);
10642
+ registryByStateElement.delete(stateElement);
10643
+ }
10644
+
10645
+ /**
10646
+ * watch/processWatchDeclaration.ts
10647
+ *
10648
+ * `$watch: { "<path>": (cur, prev, ...indexes) => void }` 宣言マップを解析し、
10649
+ * IWatchEntry を構築して watchRegistry に一括登録する
10650
+ * (docs/state-watch-hook-design.md §2-2 / §8)。
10651
+ *
10652
+ * `$streams` の processStreamsDeclaration と対称だが、**キーが宣言名ではなくパス**である
10653
+ * ぶん検証が異なる(`.` / `*` を許可し、代わりにパスとしての妥当性を見る)。
10654
+ *
10655
+ * 依存グラフ登録(§8)がこの関数の要点:
10656
+ * `setPathInfo` は BindingSession(= DOM バインディング登録)からしか呼ばれないため、
10657
+ * 静的依存グラフに載るのは「バインドされたパス」だけである。watch を宣言しただけでは
10658
+ * walkDependency がそのパスを知らず、`items` への代入で `items.*.price` がバッチに載らない
10659
+ * = ハンドラが黙って一度も発火しない。宣言時に自分で登録することでこれを塞ぐ。
10660
+ *
10661
+ * 呼び出しは stateElement の `_pathSet` クリア後・getterPaths 確定後であること
10662
+ * (State の `_state` セッターが順序を保証する)。
10663
+ */
10664
+ /**
10665
+ * `$watch` 宣言を registry へ反映し、監視対象パスの集合を返す。
10666
+ *
10667
+ * 宣言が無い(または空)なら **null** を返す。呼び出し側(State)はこれを
10668
+ * `watchPaths` に保持し、setByAddress のホットパスは `!== null` の分岐 1 個で
10669
+ * 抜けられる(ゼロコスト契約、§10)。
10670
+ */
10671
+ function processWatchDeclaration(stateElement, state) {
10672
+ const declared = state[STATE_WATCH_NAME];
10673
+ if (typeof declared === "undefined") {
10674
+ return null;
10675
+ }
10676
+ if (typeof declared !== "object" || declared === null) {
10677
+ raiseError(`${STATE_WATCH_NAME} must be an object mapping state paths to handler functions.`);
10678
+ }
10679
+ const entries = new Map();
10680
+ const paths = new Set();
10681
+ let order = 0;
10682
+ for (const [path, handler] of Object.entries(declared)) {
10683
+ if (typeof handler !== "function") {
10684
+ raiseError(`${STATE_WATCH_NAME} entry "${path}" must be a function.`);
10685
+ }
10686
+ if (path.length === 0) {
10687
+ raiseError(`${STATE_WATCH_NAME} entry name must be a non-empty state path.`);
10688
+ }
10689
+ if (path.startsWith("$")) {
10690
+ raiseError(`${STATE_WATCH_NAME} entry "${path}" must not start with "$" (reserved namespace).`);
10691
+ }
10692
+ // 越境 watch は不採用(設計 D8)。他 state のアドレスは発火対象にしないため、
10693
+ // `@stateName` 付きのパスは受け取った時点で落とす(黙って発火しないより良い)。
10694
+ if (path.includes(STATE_NAME_SEPARATOR)) {
10695
+ raiseError(`${STATE_WATCH_NAME} entry "${path}" must not target another state ("${STATE_NAME_SEPARATOR}" is not allowed); watch only paths of its own state.`);
10696
+ }
10697
+ // Object.prototype の継承名は `path in state` 系の判定を汚すため一律拒否する
10698
+ // (processStreamsDeclaration と同じ防衛線)。
10699
+ if (path in Object.prototype) {
10700
+ raiseError(`${STATE_WATCH_NAME} entry "${path}" must not be a property name inherited from Object.prototype (e.g. "__proto__", "constructor").`);
10701
+ }
10702
+ const pathInfo = getPathInfo(path);
10703
+ // 空セグメント("a..b" / 先頭・末尾の ".")は getPathInfo が黙って受理してしまうため、
10704
+ // ここで落とす。放置すると解決不能なアドレスを依存グラフへ登録することになる。
10705
+ for (const segment of pathInfo.segments) {
10706
+ if (segment.length === 0) {
10707
+ raiseError(`${STATE_WATCH_NAME} entry "${path}" has an empty path segment.`);
10708
+ }
10709
+ }
10710
+ if (pathInfo.wildcardCount > MAX_WILDCARD_DEPTH) {
10711
+ raiseError(`${STATE_WATCH_NAME} entry "${path}" exceeds the maximum wildcard depth (${MAX_WILDCARD_DEPTH}).`);
10712
+ }
10713
+ entries.set(path, {
10714
+ path,
10715
+ pathInfo,
10716
+ handler: handler,
10717
+ order: order++,
10718
+ });
10719
+ paths.add(path);
10720
+ // 依存グラフ登録(§8)。"for" 以外の bindingType は親 → 子の staticDependency
10721
+ // チェーンを生やすだけで listPaths / elementPaths を触らない(State.setPathInfo 参照)。
10722
+ stateElement.setPathInfo(path, "prop");
10723
+ }
10724
+ setWatchEntries(stateElement, entries);
10725
+ return paths.size > 0 ? paths : null;
10726
+ }
10727
+
10728
+ /**
10729
+ * watch/computedSnapshots.ts
10730
+ *
10731
+ * watch 対象の computed(getter)の「前回評価値」台帳
10732
+ * (docs/state-watch-hook-design.md §5)。
10733
+ *
10734
+ * getter は `setByAddress` を通らないので、スカラ書き込み用の旧値台帳
10735
+ * (watch/prevValues.ts)には載らない。`prev` を渡すには前回の評価値を
10736
+ * **バッチを跨いで**保持する必要があり、こちらは drain ごとにクリアしない。
10737
+ *
10738
+ * 寿命は stateElement 単位(WeakMap)。`_state` 再 set では宣言ごと作り直すため
10739
+ * 破棄する。切断では破棄しない —— 再接続時の初回評価が上書きするので、
10740
+ * 残っていても害がなく、registry を保持する扱いとも揃う。
10741
+ */
10742
+ const snapshotsByStateElement = new WeakMap();
10743
+ function getComputedSnapshot(stateElement, absAddress) {
10744
+ return snapshotsByStateElement.get(stateElement)?.get(absAddress);
10745
+ }
10746
+ function setComputedSnapshot(stateElement, absAddress, value) {
10747
+ let snapshots = snapshotsByStateElement.get(stateElement);
10748
+ if (typeof snapshots === "undefined") {
10749
+ snapshots = new Map();
10750
+ snapshotsByStateElement.set(stateElement, snapshots);
10751
+ }
10752
+ snapshots.set(absAddress, value);
10753
+ }
10754
+ /** `_state` 再 set で宣言ごと作り直すときに破棄する */
10755
+ function clearComputedSnapshots(stateElement) {
10756
+ snapshotsByStateElement.delete(stateElement);
10757
+ }
10758
+
10759
+ /**
10760
+ * watch/prevValues.ts
10761
+ *
10762
+ * `$watch` ハンドラへ渡す `prev`(バッチ開始時点の値)の台帳
10763
+ * (docs/state-watch-hook-design.md §4-1)。
10764
+ *
10765
+ * 記録するのは **watch 宣言済みパスへの書き込みだけ**、かつ **バッチ内で最初の 1 回だけ**
10766
+ * (first-write-wins)。したがって `prev` は「そのバッチが始まる前の値」、`cur` は
10767
+ * drain 時点の確定値になる。同一バッチ内の中間値は観測できない(§3-4)。
10768
+ *
10769
+ * 値の出どころは same-value guard が既に読んでいる旧値であり、watch のために
10770
+ * 追加の getByAddress は行わない(§10)。その帰結として:
10771
+ * - 参照型(object / array)は guard が素通しするので `prev` は undefined
10772
+ * - `config.sameValueGuard = false` でも undefined
10773
+ * - `$postUpdate` / stream の status 通知は setByAddress を通らないので undefined
10774
+ *
10775
+ * 台帳は drain 終端(watchRuntime)でクリアされる。drain の外で書き込まれた分が
10776
+ * 次のバッチへ持ち越されることはない。
10777
+ */
10778
+ const prevValueByAbsoluteStateAddress = new Map();
10779
+ /**
10780
+ * バッチ内で最初の書き込みのときだけ旧値を記録する(first-write-wins)。
10781
+ */
10782
+ function recordPrevValue(absAddress, oldValue) {
10783
+ if (prevValueByAbsoluteStateAddress.has(absAddress)) {
10784
+ return;
10785
+ }
10786
+ prevValueByAbsoluteStateAddress.set(absAddress, oldValue);
10787
+ }
10788
+ /**
10789
+ * 記録済みの旧値を返す。記録が無ければ undefined(§4-1 の「prev を保証しない」経路)。
10790
+ */
10791
+ function getPrevValue(absAddress) {
10792
+ return prevValueByAbsoluteStateAddress.get(absAddress);
10793
+ }
10794
+ /**
10795
+ * 台帳をクリアする(drain 終端で必ず呼ぶ)。
10796
+ */
10797
+ function clearPrevValues() {
10798
+ prevValueByAbsoluteStateAddress.clear();
10799
+ }
10800
+
10801
+ /**
10802
+ * watch/watchRuntime.ts
10803
+ *
10804
+ * `$watch` の発火(docs/state-watch-hook-design.md §3 / §7)。
10805
+ *
10806
+ * updater の drain 終了フックに 1 つだけリスナーを登録し、バッチに載った絶対アドレスと
10807
+ * 宣言済み watch パスを突き合わせて発火する。**binding の有無に関係なくバッチへ載る**ので、
10808
+ * これが headless 購読の実体になる(binding 駆動の `$updatedCallback` との違い)。
10809
+ *
10810
+ * 実行順序(設計書 §3-2):
10811
+ * - 機構間は優先度で固定(`$updatedCallback` → `$watch` → `$streams` restart)。
10812
+ * `$updatedCallback` が先なのは binding 適用ループの内側で呼ばれる構造的必然。
10813
+ * - watch ハンドラ間は `$watch` の宣言順(entry.order)。利用者が順序に意思を持てる唯一の層。
10814
+ * - 同一パスの複数行は indexes 昇順。
10815
+ *
10816
+ * 収集と発火を 2 相に分ける理由:
10817
+ * 1. ハンドラ内の書き込みが registry / active 集合を同期的に変えうる(`_state` 再 set・切断)
10818
+ * ため、発火直前に live 再チェックが要る(`$streams` の restart hits と同型)。
10819
+ * 2. 上記の順序規約のために hits をソートする必要がある(バッチの反復順は enqueue 順)。
10820
+ */
10821
+ /**
10822
+ * この stateElement の `$watch` を有効化する(`State.connectedCallback` /接続中の
10823
+ * `_state` 再 set から呼ばれる。無効化は `clearWatchRegistry`)。
10824
+ *
10825
+ * `addActiveWatchStateElement` の薄いラッパではなく、**State が runtime を import する
10826
+ * 経路をここに一本化する**意味がある: drain リスナーの登録はこのモジュールの
10827
+ * 初期化副作用なので、registry だけを import すると発火機構ごと落ちる。
10828
+ * `$streams` の `startStreams` と対称の位置づけ。
10829
+ *
10830
+ * **宣言が 1 つも無ければ active 集合に入れない**(`startStreams` の
10831
+ * `entries.size === 0` early return と同型)。ここを無条件にすると active 集合が
10832
+ * 「接続中の全 `<wcs-state>`」になり、`fireWatchOnUpdateBatch` の early return が
10833
+ * 実アプリで効かなくなる = `$watch` 未使用アプリの drain にも収集ループが乗る
10834
+ * (ゼロコスト契約、設計書 §10 / 実装計画 P16)。
10835
+ */
10836
+ function startWatch(stateElement) {
10837
+ if (getWatchEntries(stateElement).size === 0) {
10838
+ return;
10839
+ }
10840
+ addActiveWatchStateElement(stateElement);
10841
+ primeComputedWatches(stateElement);
10842
+ }
10843
+ /**
10844
+ * watch 対象の computed(getter)を 1 回評価する(設計書 §5-2 の eager 化、C-3)。
10845
+ *
10846
+ * これが要るのは、getter の依存(dynamicDependency)が**評価時にしか張られない**ため。
10847
+ * 一度も評価されていない getter は依存グラフに載らず、依存の書き込みが walkDependency で
10848
+ * そのパスへ到達しないので、バッチにも載らず watch が永久に発火しない。ここで 1 回
10849
+ * 読むことで依存が張られ、同時に `prev` の初期スナップショットが埋まる。
10850
+ *
10851
+ * **これが「watch した getter は lazy でなくなる」の実体**であり、設計書 §5-2 で
10852
+ * 規範として明記している副作用(毎バッチ評価・例外の表面化・依存の再登録)の起点。
10853
+ *
10854
+ * ワイルドカードを含む getter パス(`items.*.tax` など)は対象外: 初回評価に行ごとの
10855
+ * indexes が要り、全行評価は宣言しただけでリスト全体を舐めることになる。この形は
10856
+ * 「DOM にバインドされていれば発火する」ままとし、§5-3 に制約として書く。
10857
+ */
10858
+ function primeComputedWatches(stateElement) {
10859
+ // 宣言が 1 つ以上あることは startWatch が保証済み
10860
+ const targets = [];
10861
+ for (const entry of getWatchEntries(stateElement).values()) {
10862
+ if (isScalarComputed(stateElement, entry)) {
10863
+ targets.push(entry);
10864
+ }
10865
+ }
10866
+ if (targets.length === 0) {
10867
+ // getter を watch していないなら createState ごと省く(宣言の大半はこちら)
10868
+ return;
10869
+ }
10870
+ stateElement.createState("readonly", (state) => {
10871
+ for (const entry of targets) {
10872
+ try {
10873
+ setComputedSnapshot(stateElement, absoluteAddressOf(stateElement, entry), state[entry.path]);
10874
+ }
10875
+ catch (e) {
10876
+ // 初回評価の throw は接続を巻き添えにしない(発火時と同じ隔離方針、§7-1)
10877
+ reportWatchError(stateElement, entry.path, "prime", e);
10878
+ }
10879
+ }
10880
+ });
10881
+ }
10882
+ /** ワイルドカードを含まない watch パスの絶対アドレス(listIndex は常に null) */
10883
+ function absoluteAddressOf(stateElement, entry) {
10884
+ return createAbsoluteStateAddress(getAbsolutePathInfo(stateElement, entry.pathInfo), null);
10885
+ }
10886
+ /**
10887
+ * 前回評価値のスナップショット台帳(computedSnapshots)に載せる entry か。
10888
+ *
10889
+ * ワイルドカードを含む getter を**除く**のが要点。除かないと台帳のキーが行ごとの
10890
+ * 絶対アドレス(= listIndex を強参照)になり、prune 経路が `_state` 再 set しか
10891
+ * 無いため、行が入れ替わり続けるページで単調増加する(リスト置換 5 回で 2→10 件を実測)。
10892
+ * そもそもワイルドカード getter は eager 化の対象外(設計書 §5-3)なので、
10893
+ * 「初回評価もしない・前回値も持たない」で primeComputedWatches と対称になる。
10894
+ * この形の `prev` は常に undefined(getter は setByAddress を通らない)。
10895
+ */
10896
+ function isScalarComputed(stateElement, entry) {
10897
+ return entry.pathInfo.wildcardCount === 0 && stateElement.getterPaths.has(entry.path);
10898
+ }
10899
+ /**
10900
+ * throw を報告する(設計書 §7-1)。
10901
+ *
10902
+ * `console.error` だけだと **devtools からは「静かに握られた失敗」が見えない**。
10903
+ * watch は drain フックを `$streams` と共有しており、例外を watch 側で閉じるのが
10904
+ * 前提なので、閉じた事実をここで観測可能にしておく必要がある。
10905
+ * イベント生成は必ず `devtoolsSink !== null` の内側で行う(sink のコスト規範)。
10906
+ */
10907
+ const WATCH_ERROR_SUBJECT = {
10908
+ prime: "initial evaluation of",
10909
+ evaluate: "evaluation of",
10910
+ handler: "handler for",
10911
+ };
10912
+ function reportWatchError(stateElement, path, phase, error) {
10913
+ console.error(`[@wcstack/state] $watch ${WATCH_ERROR_SUBJECT[phase]} "${path}" threw.`, error);
10914
+ if (devtoolsSink !== null) {
10915
+ devtoolsSink({
10916
+ type: "state:watch-error",
10917
+ phase,
10918
+ stateName: stateElement.name,
10919
+ path,
10920
+ error,
10921
+ });
10922
+ }
10923
+ }
10924
+ function fireWatchOnUpdateBatch(batch) {
10925
+ const activeStateElements = getActiveWatchStateElements();
10926
+ try {
10927
+ if (activeStateElements.size === 0) {
10928
+ // watch 未使用アプリの drain に配列・イテレータ割り当てのコストを載せない。
10929
+ // ここも finally を通す: 宣言済みの state が切断されている間(active からは
10930
+ // 外れるが watchPaths は残る)の書き込みで台帳に旧値が積まれるため、
10931
+ // クリアを早期 return の外に置くと次のバッチどころか永久に残る。
10932
+ return;
10933
+ }
10934
+ const depth = consumeWatchChainDepth();
10935
+ if (depth > MAX_WATCH_CHAIN_DEPTH) {
10936
+ // 打ち切るのは watch の発火のみ。値と binding 適用は巻き戻さない
10937
+ // (伝播 hop 上限超過時の quarantine と同じ姿勢、§7-2)。
10938
+ const paths = Array.from(batch, (absAddress) => absAddress.absolutePathInfo.pathInfo.path);
10939
+ console.error(`[@wcstack/state] $watch chain depth limit exceeded; watch handlers for this batch were skipped.`, { maxDepth: MAX_WATCH_CHAIN_DEPTH, paths });
10940
+ if (devtoolsSink !== null) {
10941
+ devtoolsSink({ type: "state:watch-chain-limit", maxDepth: MAX_WATCH_CHAIN_DEPTH, paths });
10942
+ }
10943
+ return;
10944
+ }
10945
+ // --- 収集フェーズ ---
10946
+ const hits = [];
10947
+ for (const absAddress of batch) {
10948
+ // stateName 文字列ではなく stateElement 参照で引く。AbsolutePathInfo は
10949
+ // stateElement 単位でキャッシュされるので、同名 state が複数の rootNode に
10950
+ // 居ても取り違えない(address/AbsolutePathInfo.ts)。他 state のアドレスは
10951
+ // ここで自然に落ちる = 越境しない(設計 D8)。
10952
+ const stateElement = absAddress.absolutePathInfo.stateElement;
10953
+ if (!activeStateElements.has(stateElement)) {
10954
+ continue;
10955
+ }
10956
+ const entry = getWatchEntries(stateElement).get(absAddress.absolutePathInfo.pathInfo.path);
10957
+ if (typeof entry === "undefined") {
10958
+ continue;
10959
+ }
10960
+ let indexes = [];
10961
+ if (entry.pathInfo.wildcardCount > 0) {
10962
+ if (absAddress.listIndex === null) {
10963
+ // ワイルドカードパスなのに行が特定できないヒット(リストの依存展開で載る
10964
+ // 中間アドレス等)。indexes を空のまま発火すると cur の解決($resolve)が
10965
+ // 「indexes 不足」で throw し、例外隔離に落ちて console.error だけが残る。
10966
+ // 行が定まらない以上ハンドラに渡せる意味が無いので、収集段階で落とす。
10967
+ continue;
10968
+ }
10969
+ indexes = getScopedIndexes(absAddress.listIndex, entry.pathInfo.wildcardCount);
10970
+ }
10971
+ hits.push({ stateElement, entry, absAddress, indexes });
10972
+ }
10973
+ if (hits.length === 0) {
10974
+ return;
10975
+ }
10976
+ hits.sort(compareHits);
10977
+ // --- 発火フェーズ ---
10978
+ beginWatchFiring(depth);
10979
+ try {
10980
+ for (const hit of hits) {
10981
+ // 先行ハンドラが同期的に切断や `_state` 再 set を行い得るため、発火直前に
10982
+ // 「まだ active か」「entry が現行 registry のものか」を再確認する。
10983
+ if (!activeStateElements.has(hit.stateElement) ||
10984
+ getWatchEntries(hit.stateElement).get(hit.entry.path) !== hit.entry) {
10985
+ continue;
10986
+ }
10987
+ fireOne(hit);
10988
+ }
10989
+ }
10990
+ finally {
10991
+ endWatchFiring();
10992
+ }
10993
+ }
10994
+ finally {
10995
+ // 旧値台帳はこの drain 限りのもの。次のバッチへ持ち越さない(§4-1)。
10996
+ clearPrevValues();
10997
+ }
10998
+ }
10999
+ /**
11000
+ * 層 2(宣言順)→ 層 3(indexes 昇順)の順に比較する(設計書 §3-3)。
11001
+ */
11002
+ function compareHits(a, b) {
11003
+ if (a.entry.order !== b.entry.order) {
11004
+ return a.entry.order - b.entry.order;
11005
+ }
11006
+ const length = Math.min(a.indexes.length, b.indexes.length);
11007
+ for (let i = 0; i < length; i++) {
11008
+ if (a.indexes[i] !== b.indexes[i]) {
11009
+ return a.indexes[i] - b.indexes[i];
11010
+ }
11011
+ }
11012
+ return a.indexes.length - b.indexes.length;
11013
+ }
11014
+ /**
11015
+ * ハンドラ 1 つを発火する。**例外はここで閉じる**(設計書 §7-1)。
11016
+ *
11017
+ * drain リスナーの throw は握りつぶさない契約(updater.ts)なので、watch 側で捕まえないと
11018
+ * 1 つのユーザー例外が他の watch と `$streams` の restart を巻き添えにする。
11019
+ * `$connectedCallback` / `$updatedCallback` の loud fail とは意図的に異なる扱い。
11020
+ *
11021
+ * 報告は throw 元で分ける: `cur` の解決(watch した getter の強制評価 = §5-2 の副作用 b)と
11022
+ * ハンドラ本体では原因も直し方も違うため、同じ文言に丸めない。
11023
+ */
11024
+ function fireOne(hit) {
11025
+ const { stateElement, entry, absAddress, indexes } = hit;
11026
+ // スカラ getter は setByAddress を通らないので旧値台帳に載らない。前回評価値の
11027
+ // スナップショット(バッチを跨いで生きる別台帳)から prev を取る(§5-2)。
11028
+ const isComputed = isScalarComputed(stateElement, entry);
11029
+ try {
11030
+ stateElement.createState("writable", (state) => {
11031
+ let cur;
11032
+ try {
11033
+ // 強制評価はここ。dirty なら再計算され、その結果が cur になる
11034
+ cur = readCurrentValue(state, entry, indexes);
11035
+ }
11036
+ catch (e) {
11037
+ // cur が得られない以上ハンドラは呼べない。次の hit へ進む
11038
+ reportWatchError(stateElement, entry.path, "evaluate", e);
11039
+ return;
11040
+ }
11041
+ const prev = isComputed ? getComputedSnapshot(stateElement, absAddress) : getPrevValue(absAddress);
11042
+ if (isComputed) {
11043
+ // ハンドラ本体が throw しても次回の prev は「今回の評価値」であるべきなので、
11044
+ // handler 呼び出しより前に更新する
11045
+ setComputedSnapshot(stateElement, absAddress, cur);
11046
+ }
11047
+ entry.handler.call(state, cur, prev, ...indexes);
11048
+ });
11049
+ }
11050
+ catch (e) {
11051
+ reportWatchError(stateElement, entry.path, "handler", e);
11052
+ }
11053
+ }
11054
+ function readCurrentValue(state, entry, indexes) {
11055
+ if (entry.pathInfo.wildcardCount === 0) {
11056
+ return state[entry.path];
11057
+ }
11058
+ // ワイルドカードを含むパスは素の読みでは解決できない。getScopedIndexes が返した列は
11059
+ // そのまま $resolve の引数として使える(list/wildcardLevel.ts の往復契約)。
11060
+ return state.$resolve(entry.path, indexes);
11061
+ }
11062
+ // 優先度で `$streams` の restart より先に固定する(設計書 §3-2 層 1)。import 順には依存しない。
11063
+ registerUpdateBatchListener(fireWatchOnUpdateBatch, WATCH_LISTENER_PRIORITY);
10073
11064
 
10074
11065
  function getterFn(name) {
10075
11066
  return function () {
@@ -11270,13 +12261,22 @@ function selectExpansionIndexes(context, sourcePath, _lastValue, _newValue, list
11270
12261
  const EMPTY_PATH_INFOS = [];
11271
12262
  /**
11272
12263
  * 位置だけが変わった行(movedRows)で展開すべきパス群を求める。
11273
- * `${listPath}.*` の静的 subtree を辿り、$1 等を読んだ実績のある getter
12264
+ * `${listPath}.*` 配下にある、$1 等を読んだ実績のある getter
11274
12265
  * (indexDependentGetterPaths)だけを返す。行の同一性・listIndex は保たれ
11275
12266
  * index 以外の入力が不変なので、index を読まない getter / 値パスは再評価不要。
11276
12267
  * 戻り値:
11277
12268
  * - IPathInfo[](空可): この各パスだけを行の listIndex で展開する
11278
12269
  * - null: ネストしたワイルドカード配下に index 依存 getter がある
11279
12270
  * (listIndex の階数が合わず個別展開できない)→ 呼び出し側で行全体展開に倒す
12271
+ *
12272
+ * 配下判定は staticMap の subtree 走査ではなく indexDependentGetterPaths 側の
12273
+ * プレフィックス照合で行う。静的依存グラフは `State.setPathInfo` が
12274
+ * 「バインドされたパスから親方向へ」張るため、DOM にバインドされていない中間
12275
+ * getter(`.label` だけを描画し `.rank` は `.label` からしか読まれない綴り)は
12276
+ * subtree に現れない。そこを走査すると index 依存 getter を取りこぼし、
12277
+ * 「index を読む getter が subtree に無い=位置のみ変わった行の値は不変」という
12278
+ * 呼び出し側の判断が偽になって、移動行の getter が古い値のまま残る。
12279
+ * この集合は $1 を読んだ getter の数しか持たないので、走査コストも subtree より小さい。
11280
12280
  */
11281
12281
  function getMovedRowExpansionPaths(context, wildcardPath, depPathInfo) {
11282
12282
  const indexGetters = context.stateElement.indexDependentGetterPaths;
@@ -11284,26 +12284,16 @@ function getMovedRowExpansionPaths(context, wildcardPath, depPathInfo) {
11284
12284
  return EMPTY_PATH_INFOS;
11285
12285
  }
11286
12286
  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);
12287
+ const prefix = wildcardPath + DELIMITER;
12288
+ for (const path of indexGetters) {
12289
+ if (path !== wildcardPath && !path.startsWith(prefix)) {
12290
+ continue;
11297
12291
  }
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
- }
12292
+ const pathInfo = getPathInfo(path);
12293
+ if (pathInfo.wildcardCount !== depPathInfo.wildcardCount) {
12294
+ return null;
11306
12295
  }
12296
+ (result ??= []).push(pathInfo);
11307
12297
  }
11308
12298
  return result ?? EMPTY_PATH_INFOS;
11309
12299
  }
@@ -11531,6 +12521,21 @@ function walkDependency(stateName, stateElement, startAddress, staticDependency,
11531
12521
  * - finallyで必ず更新情報を登録し、再描画や依存解決に利用
11532
12522
  * - getter/setter経由のスコープ切り替えも考慮した設計
11533
12523
  */
12524
+ /**
12525
+ * `$watch` の `prev` 台帳へ旧値を記録する(docs/state-watch-hook-design.md §4-1)。
12526
+ *
12527
+ * same-value guard が既に読んだ旧値だけを使い、watch のための追加読みはしない。
12528
+ * `$watch` 未宣言時のコストは `watchPaths` の null 判定 1 個に収める(§10)。
12529
+ */
12530
+ function recordWatchPrevValue(stateElement, path, absAddress, oldValue, hasOldValue) {
12531
+ const watchPaths = stateElement.watchPaths;
12532
+ if (watchPaths == null || !hasOldValue) {
12533
+ return;
12534
+ }
12535
+ if (watchPaths.has(path)) {
12536
+ recordPrevValue(absAddress, oldValue);
12537
+ }
12538
+ }
11534
12539
  // Phase 3: 書き込み時点の因果 context を update record に付与する。
11535
12540
  // binding 経由の書き込みは呼び出し元の dynamic scope から context を引き継ぎ、
11536
12541
  // binding 外からの API update は新しい transaction を開始する(設計書 §4 規則 1)。
@@ -11764,6 +12769,7 @@ function setByAddressCore(target, address, value, receiver, handler, keyedMergeP
11764
12769
  hasOldValue: devHasOldValue,
11765
12770
  });
11766
12771
  }
12772
+ recordWatchPrevValue(stateElement, path, absAddress, devOldValue, devHasOldValue);
11767
12773
  try {
11768
12774
  if (key === undefined) {
11769
12775
  raiseError(`address.listIndex?.index is undefined path: ${path}`);
@@ -11814,6 +12820,7 @@ function setByAddressCore(target, address, value, receiver, handler, keyedMergeP
11814
12820
  hasOldValue: devHasOldValue,
11815
12821
  });
11816
12822
  }
12823
+ recordWatchPrevValue(stateElement, path, absAddress, devOldValue, devHasOldValue);
11817
12824
  try {
11818
12825
  if (isSwappable) {
11819
12826
  return _setByAddressWithSwap(target, address, absAddress, value, receiver, handler, keyedMergePath);
@@ -12558,14 +13565,19 @@ class InnerStateProxyHandler {
12558
13565
  */
12559
13566
  _outerLoopContext(innerPathInfo, outerAbsPathInfo) {
12560
13567
  const outerWildcardCount = outerAbsPathInfo.pathInfo.wildcardCount;
13568
+ // 段数の照合は外側スコープの**実 arity**(パスの段数 + そのスコープの Δ)で行う。
13569
+ // 境界 1 枚なら外側は Δ=0 で従来と同値、2 枚以上あるときに中間スコープの Δ を
13570
+ // 数えないと候補が両方とも外れて loopContext が null になる(§1.12)。
13571
+ // 添字(wildcardPaths)に使うのは Δ を含まない段数のままであることに注意。
13572
+ const outerArity = getScopeArity(outerAbsPathInfo.stateElement, outerAbsPathInfo.pathInfo);
12561
13573
  const nodeLoopContext = getLoopContextByNode(this._webComponent);
12562
- if (nodeLoopContext !== null && nodeLoopContext.listIndex.length === outerWildcardCount) {
13574
+ if (nodeLoopContext !== null && nodeLoopContext.listIndex.length === outerArity) {
12563
13575
  return nodeLoopContext;
12564
13576
  }
12565
13577
  if (outerWildcardCount > 0) {
12566
13578
  const address = getCrossBoundaryAddress(this._innerStateElement, innerPathInfo.path);
12567
13579
  const listIndex = address?.listIndex ?? null;
12568
- if (listIndex !== null && listIndex.length === outerWildcardCount) {
13580
+ if (listIndex !== null && listIndex.length === outerArity) {
12569
13581
  const outerWildcardPath = outerAbsPathInfo.pathInfo.wildcardPaths[outerWildcardCount - 1];
12570
13582
  return createStateAddress(getPathInfo(outerWildcardPath), listIndex);
12571
13583
  }
@@ -12837,6 +13849,8 @@ class State extends HTMLElementBase {
12837
13849
  _dynamicDependency = new Map();
12838
13850
  _staticDependency = new Map();
12839
13851
  _pathSet = new Set();
13852
+ // `$watch` 宣言の監視対象パス。宣言が無ければ null(setByAddress のゼロコスト契約)
13853
+ _watchPaths = null;
12840
13854
  _version = 0;
12841
13855
  _rootNode = null;
12842
13856
  _boundComponent = null;
@@ -12916,10 +13930,21 @@ class State extends HTMLElementBase {
12916
13930
  // $listKeys: 宣言が無ければ null のままで、setByAddress のキー突合経路には
12917
13931
  // 一切入らない(docs/state-list-key-design.md §7-1)。再 set で必ず置き換える。
12918
13932
  this._listKeys = processListKeysDeclaration(value);
13933
+ // $watch: 旧宣言のハンドラが残らないよう registry を落としてから新宣言を解析する。
13934
+ // _pathSet.clear() の後であること(依存グラフ登録をやり直す必要がある、
13935
+ // docs/state-watch-hook-design.md §8)。宣言が無ければ watchPaths は null で、
13936
+ // setByAddress の旧値キャプチャには一切入らない(§10 のゼロコスト契約)。
13937
+ clearWatchRegistry(this);
13938
+ // computed の前回評価値も宣言と寿命を共にする(旧宣言の値を新しい watch の
13939
+ // prev として渡さない)。切断では消さない — 再接続の初回評価が上書きする。
13940
+ clearComputedSnapshots(this);
13941
+ this._watchPaths = processWatchDeclaration(this, value);
12919
13942
  // 接続中の再 set(S13)は新宣言で即再起動する。
12920
13943
  // 初回(_initialize 中)は _initialized が false なのでここでは起動されず、
12921
13944
  // connectedCallback 側の startStreams($connectedCallback 完了後)が担う。
12922
13945
  if (this._initialized && this._rootNode !== null && !inSsr()) {
13946
+ // watch は stream より先に有効化する(stream の起動時書き込みを観測できるように)
13947
+ startWatch(this);
12923
13948
  startStreams(this);
12924
13949
  // $connectedCallback 実行中の再 set(setInitialState)では、ここで新宣言が
12925
13950
  // 起動済みのため connectedCallback 末尾の startStreams を skip させる。
@@ -13057,6 +14082,36 @@ class State extends HTMLElementBase {
13057
14082
  bindWebComponent(this, this._boundComponent, this._boundComponentStateProp, state);
13058
14083
  }
13059
14084
  }
14085
+ /**
14086
+ * Light DOM の mapped コンポーネントが、自分のサブツリーのバインディングを張る(§1.13)。
14087
+ *
14088
+ * Shadow DOM 形では子スコープが別 rootNode にあり、`setStateElementByName` の初回登録から
14089
+ * その root ぶんの `buildBindings` が別パスとして起動する。Light DOM ではホストと同じ root に
14090
+ * いるためそのパスが存在せず、かといってホストのパスに混ぜると `@name` の解決が
14091
+ * この要素の名前登録より先に来てしまう。そこで `getSubscriberNodes` がホスト側の走査から
14092
+ * このサブツリーを外し、名前登録が済んだここで同じことを自前で行う。
14093
+ *
14094
+ * `{{ }}` の変換だけはホストのパスが root 全体に対して済ませている(純粋にテキスト操作で
14095
+ * state に依存しないため)。構造フラグメントの収集は fragment info を rootNode + state 名で
14096
+ * 登録するので state 依存であり、ホストのパスからは外してここで走らせる。
14097
+ *
14098
+ * ループ文脈を null で渡すのは Shadow DOM 形(`initializeBindings(shadowRoot, null)`)と
14099
+ * 揃えるため —— 子孫の `getLoopContextByNode` はコンポーネント要素まで遡って
14100
+ * 親スコープの行を見つける。
14101
+ */
14102
+ _initializeLightDomComponentScope() {
14103
+ const component = this._boundComponent;
14104
+ if (component === null || this.parentNode !== component) {
14105
+ // Shadow DOM 形(parentNode が ShadowRoot)は対象外
14106
+ return;
14107
+ }
14108
+ if (!component.hasAttribute(config.bindAttributeName)) {
14109
+ // plain 形はホストのパスに含まれたままなので、ここで張ると二重になる
14110
+ return;
14111
+ }
14112
+ collectStructuralFragments(this._rootNode, component);
14113
+ initializeBindings(component, null);
14114
+ }
13060
14115
  /**
13061
14116
  * mapped な `bind-component` が切断 → 再接続したときに、束ねているパスを読み直させる(§1.9)。
13062
14117
  *
@@ -13158,6 +14213,9 @@ class State extends HTMLElementBase {
13158
14213
  await this._initializeBindWebComponent();
13159
14214
  await this._initialize();
13160
14215
  this._initialized = true;
14216
+ // 名前登録(_initialize の末尾)が済んだこの時点でなければ、子スコープの
14217
+ // `@name` 参照が解決できない(§1.13)
14218
+ this._initializeLightDomComponentScope();
13161
14219
  this._resolveInitialize?.();
13162
14220
  }
13163
14221
  else if (!this._dcc && getStateElementByName(this._rootNode, this._name) !== this) {
@@ -13209,6 +14267,19 @@ class State extends HTMLElementBase {
13209
14267
  // _streamsStartedGeneration ガード: $connectedCallback 内の setInitialState
13210
14268
  // (接続中の再 set)で _state セッター側が新宣言を起動済みの場合は skip する
13211
14269
  // (skip しないと同一 connect サイクルで source が 2 回起動する、設計書 §2-3)。
14270
+ // $watch の有効化($connectedCallback 完了後 = 初期化中の書き込みは購読対象外)。
14271
+ // ガードは startStreams と同じ理由で必要(await 中の切断・再接続)。SSR では
14272
+ // 走らせない — ハンドラの副作用がサーバとクライアントで二重に実行されるため
14273
+ // (docs/state-watch-hook-design.md §11)。
14274
+ // startStreams より先に呼ぶ: stream の起動時書き込み(initial リセット・status 遷移)は
14275
+ // watch から観測できるべきで、逆向きは要らない。
14276
+ // 再入不要: 接続中の _state 再 set は _state セッター側で startWatch 済みだが、
14277
+ // startWatch は Set への add で冪等なので $streams のような世代ガードは要らない。
14278
+ if (!inSsr() &&
14279
+ this._rootNode !== null &&
14280
+ connectGeneration === this._connectGeneration) {
14281
+ startWatch(this);
14282
+ }
13212
14283
  if (!inSsr() &&
13213
14284
  this._rootNode !== null &&
13214
14285
  connectGeneration === this._connectGeneration &&
@@ -13238,6 +14309,10 @@ class State extends HTMLElementBase {
13238
14309
  // registry は残るため再接続後の初回アクセスで同内容の proxy が再生成される)。
13239
14310
  abortAllStreams(this);
13240
14311
  clearStreamNamespace(this);
14312
+ // watch は発火対象から外すだけで registry は保持する(stream の abortAllStreams と
14313
+ // 同じ二段構え、設計書 §9)。registry まで捨てると、_state セッターが再度走らない
14314
+ // 再接続で宣言を作り直せず watch が二度と発火しない。
14315
+ deactivateWatch(this);
13241
14316
  this._rootNode = null;
13242
14317
  }
13243
14318
  }
@@ -13257,6 +14332,9 @@ class State extends HTMLElementBase {
13257
14332
  get listKeys() {
13258
14333
  return this._listKeys;
13259
14334
  }
14335
+ get watchPaths() {
14336
+ return this._watchPaths;
14337
+ }
13260
14338
  get elementPaths() {
13261
14339
  return this._elementPaths;
13262
14340
  }
@@ -13568,6 +14646,8 @@ const builtinFilterMeta = {
13568
14646
  mul: { description: "乗算", hasArgs: true, resultType: "number", acceptTypes: ["number"], minArgs: 1, maxArgs: 1, argTypes: ["number"] },
13569
14647
  div: { description: "除算", hasArgs: true, resultType: "number", acceptTypes: ["number"], minArgs: 1, maxArgs: 1, argTypes: ["number"] },
13570
14648
  mod: { description: "剰余", hasArgs: true, resultType: "number", acceptTypes: ["number"], minArgs: 1, maxArgs: 1, argTypes: ["number"] },
14649
+ abs: { description: "絶対値", hasArgs: false, resultType: "number", acceptTypes: ["number"], minArgs: 0, maxArgs: 0 },
14650
+ clamp: { description: "範囲内に丸める (min,max)", hasArgs: true, resultType: "number", acceptTypes: ["number"], minArgs: 2, maxArgs: 2, argTypes: ["number", "number"] },
13571
14651
  // 数値フォーマット
13572
14652
  fix: { description: "固定小数点表記", hasArgs: true, resultType: "string", acceptTypes: ["number"], minArgs: 0, maxArgs: 1, argTypes: ["number"] },
13573
14653
  locale: { description: "ロケール形式で数値フォーマット", hasArgs: true, resultType: "string", acceptTypes: ["number"], minArgs: 0, maxArgs: 1, argTypes: ["string"] },
@@ -13581,6 +14661,8 @@ const builtinFilterMeta = {
13581
14661
  pad: { description: "パディング (length[,char])", hasArgs: true, resultType: "string", acceptTypes: ["string"], minArgs: 1, maxArgs: 2, argTypes: ["number", "string"] },
13582
14662
  rep: { description: "繰り返し (count)", hasArgs: true, resultType: "string", acceptTypes: ["string"], minArgs: 1, maxArgs: 1, argTypes: ["number"] },
13583
14663
  rev: { description: "文字順を反転", hasArgs: false, resultType: "string", acceptTypes: ["string"], minArgs: 0, maxArgs: 0 },
14664
+ truncate: { description: "切り詰めて省略記号 (length[,suffix])", hasArgs: true, resultType: "string", acceptTypes: ["string"], minArgs: 1, maxArgs: 2, argTypes: ["number", "string"] },
14665
+ join: { description: "配列を連結 ([separator])", hasArgs: true, resultType: "string", acceptTypes: ["array"], minArgs: 0, maxArgs: 1, argTypes: ["string"] },
13584
14666
  // 数値パース・丸め
13585
14667
  int: { description: "整数にパース", hasArgs: false, resultType: "number", acceptTypes: ["string", "number"], minArgs: 0, maxArgs: 0 },
13586
14668
  float: { description: "浮動小数点数にパース", hasArgs: false, resultType: "number", acceptTypes: ["string", "number"], minArgs: 0, maxArgs: 0 },
@@ -13588,11 +14670,15 @@ const builtinFilterMeta = {
13588
14670
  floor: { description: "切り下げ", hasArgs: true, resultType: "number", acceptTypes: ["number"], minArgs: 0, maxArgs: 1, argTypes: ["number"] },
13589
14671
  ceil: { description: "切り上げ", hasArgs: true, resultType: "number", acceptTypes: ["number"], minArgs: 0, maxArgs: 1, argTypes: ["number"] },
13590
14672
  percent: { description: "パーセンテージ形式", hasArgs: true, resultType: "string", acceptTypes: ["number"], minArgs: 0, maxArgs: 1, argTypes: ["number"] },
14673
+ // number だけでなく string も受ける。実用チェーンは fix / percent の後ろに繋がり、
14674
+ // それらは既に string を返すため(builtinFilters.ts の unit を参照)
14675
+ unit: { description: "単位(接尾辞)を付加", hasArgs: true, resultType: "string", acceptTypes: ["number", "string"], minArgs: 1, maxArgs: 1, argTypes: ["string"] },
13591
14676
  // 日付・時刻
13592
14677
  date: { description: "ロケール形式の日付", hasArgs: false, resultType: "string", acceptTypes: "any", minArgs: 0, maxArgs: 0 },
13593
14678
  time: { description: "ロケール形式の時刻", hasArgs: false, resultType: "string", acceptTypes: "any", minArgs: 0, maxArgs: 0 },
13594
14679
  datetime: { description: "ロケール形式の日時", hasArgs: false, resultType: "string", acceptTypes: "any", minArgs: 0, maxArgs: 0 },
13595
14680
  ymd: { description: "YYYY-MM-DD 形式", hasArgs: true, resultType: "string", acceptTypes: "any", minArgs: 0, maxArgs: 1, argTypes: ["string"] },
14681
+ hms: { description: "HH:MM:SS 形式", hasArgs: true, resultType: "string", acceptTypes: "any", minArgs: 0, maxArgs: 1, argTypes: ["string"] },
13596
14682
  // 真偽値・変換
13597
14683
  falsy: { description: "偽値か判定", hasArgs: false, resultType: "boolean", acceptTypes: "any", minArgs: 0, maxArgs: 0 },
13598
14684
  truthy: { description: "真値か判定", hasArgs: false, resultType: "boolean", acceptTypes: "any", minArgs: 0, maxArgs: 0 },
@@ -13654,6 +14740,7 @@ function getWcsManifest() {
13654
14740
  STATE_EVENT_TOKENS_NAME,
13655
14741
  STATE_ON_NAME,
13656
14742
  STATE_STREAMS_NAME,
14743
+ STATE_WATCH_NAME,
13657
14744
  STATE_LIST_KEYS_NAME,
13658
14745
  STATE_STREAM_STATUS_NAMESPACE_NAME,
13659
14746
  STATE_STREAM_ERROR_NAMESPACE_NAME,