@wcstack/state 1.25.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 = ';'; // 複数バインディングの区切り
@@ -158,15 +134,59 @@ const STATE_DISCONNECTED_CALLBACK_NAME = "$disconnectedCallback";
158
134
  const STATE_UPDATED_CALLBACK_NAME = "$updatedCallback";
159
135
  const WEBCOMPONENT_STATE_READY_CALLBACK_NAME = "$stateReadyCallback";
160
136
  const STATE_BINDABLES_NAME = "$bindables";
137
+ const STATE_COMMANDS_NAME = "$commands";
161
138
  const STATE_COMMAND_TOKENS_NAME = "$commandTokens";
162
139
  const STATE_COMMAND_NAMESPACE_NAME = "$command";
163
140
  const STATE_EVENT_TOKENS_NAME = "$eventTokens";
164
141
  const STATE_ON_NAME = "$on";
165
142
  const STATE_STREAMS_NAME = "$streams";
143
+ const STATE_WATCH_NAME = "$watch";
144
+ const STATE_LIST_KEYS_NAME = "$listKeys";
166
145
  const STATE_STREAM_STATUS_NAMESPACE_NAME = "$streamStatus";
167
146
  const STATE_STREAM_ERROR_NAMESPACE_NAME = "$streamError";
168
147
  const DCC_DEFINITION_ATTRIBUTE = "data-wc-definition";
169
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
+
170
190
  const _cache$4 = new Map();
171
191
  let id = 0;
172
192
  function getPathInfo(path) {
@@ -612,15 +632,6 @@ function getBindingsByNode(node) {
612
632
  function setBindingsByNode(node, bindings) {
613
633
  bindingsByNode.set(node, bindings);
614
634
  }
615
- function addBindingByNode(node, binding) {
616
- const bindings = getBindingsByNode(node);
617
- if (bindings === null) {
618
- setBindingsByNode(node, [binding]);
619
- }
620
- else {
621
- bindings.push(binding);
622
- }
623
- }
624
635
 
625
636
  const STRUCTURAL_BINDING_TYPE_SET = new Set([
626
637
  "if",
@@ -690,6 +701,15 @@ function valueMustBeBoolean(fnName) {
690
701
  function valueMustBeDate(fnName) {
691
702
  raiseError(`filter ${fnName} requires a date value`);
692
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
+ }
693
713
 
694
714
  /**
695
715
  * builtinFilters.ts
@@ -702,7 +722,7 @@ function valueMustBeDate(fnName) {
702
722
  * - Designed for common use as both input and output filters
703
723
  *
704
724
  * Design points:
705
- * - 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.
706
726
  * - Rich type checking and error handling for option values
707
727
  * - Centralized management of filter functions with FilterWithOptions type, easy to extend
708
728
  * - Dynamic retrieval of filter functions from filter names and options via builtinFilterFn
@@ -935,6 +955,48 @@ const mod = (options) => {
935
955
  return value % Number(opt);
936
956
  };
937
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
+ };
938
1000
  /**
939
1001
  * Fixed decimal filter - formats number to fixed decimal places.
940
1002
  *
@@ -1201,6 +1263,76 @@ const percent = (options) => {
1201
1263
  return `${(value * 100).toFixed(Number(opt))}%`;
1202
1264
  };
1203
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
+ };
1204
1336
  /**
1205
1337
  * Date filter - formats Date object as localized date string.
1206
1338
  *
@@ -1264,6 +1396,27 @@ const ymd = (options) => {
1264
1396
  return `${year}${opt}${month}${opt}${day}`;
1265
1397
  };
1266
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
+ };
1267
1420
  /**
1268
1421
  * Falsy filter - checks if value is falsy.
1269
1422
  *
@@ -1354,6 +1507,8 @@ const builtinFilters = {
1354
1507
  "mul": mul,
1355
1508
  "div": div,
1356
1509
  "mod": mod,
1510
+ "abs": abs,
1511
+ "clamp": clamp,
1357
1512
  "fix": fix,
1358
1513
  "locale": locale,
1359
1514
  "uc": uc,
@@ -1365,16 +1520,20 @@ const builtinFilters = {
1365
1520
  "pad": pad,
1366
1521
  "rep": rep,
1367
1522
  "rev": rev,
1523
+ "truncate": truncate,
1524
+ "join": join,
1368
1525
  "int": int,
1369
1526
  "float": float,
1370
1527
  "round": round,
1371
1528
  "floor": floor,
1372
1529
  "ceil": ceil,
1373
1530
  "percent": percent,
1531
+ "unit": unit,
1374
1532
  "date": date,
1375
1533
  "time": time,
1376
1534
  "datetime": datetime,
1377
1535
  "ymd": ymd,
1536
+ "hms": hms,
1378
1537
  "falsy": falsy,
1379
1538
  "truthy": truthy,
1380
1539
  "defaults": defaults,
@@ -1404,11 +1563,44 @@ const builtinFilterFn = (name, options) => (filters) => {
1404
1563
  return filter(options);
1405
1564
  };
1406
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
+ }
1407
1590
  function parseFilterArgs(argsText) {
1408
1591
  const args = [];
1409
1592
  let current = '';
1410
1593
  let inQuote = null;
1411
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
+ };
1412
1604
  for (let i = 0; i < argsText.length; i++) {
1413
1605
  const char = argsText[i];
1414
1606
  if (inQuote) {
@@ -1416,7 +1608,11 @@ function parseFilterArgs(argsText) {
1416
1608
  inQuote = null;
1417
1609
  }
1418
1610
  else {
1611
+ if (firstQuoteStart === -1) {
1612
+ firstQuoteStart = current.length;
1613
+ }
1419
1614
  current += char;
1615
+ lastQuoteEnd = current.length;
1420
1616
  }
1421
1617
  }
1422
1618
  else if (char === '"' || char === "'") {
@@ -1424,15 +1620,13 @@ function parseFilterArgs(argsText) {
1424
1620
  hasQuote = true;
1425
1621
  }
1426
1622
  else if (char === ',') {
1427
- args.push(current.trim());
1428
- current = '';
1429
- hasQuote = false;
1623
+ flush();
1430
1624
  }
1431
1625
  else {
1432
1626
  current += char;
1433
1627
  }
1434
1628
  }
1435
- const last = current.trim();
1629
+ const last = finalizeArg(current, firstQuoteStart, lastQuoteEnd);
1436
1630
  if (last || hasQuote) {
1437
1631
  args.push(last);
1438
1632
  }
@@ -1785,13 +1979,97 @@ function getParseBindTextResults(node) {
1785
1979
  return [];
1786
1980
  }
1787
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
+
1788
2055
  /**
1789
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
+ *
1790
2067
  * @param root
1791
2068
  * @returns
1792
2069
  */
1793
2070
  function getSubscriberNodes(root) {
1794
2071
  const subscriberNodes = [];
2072
+ const nestedComponents = findNestedLightDomComponents(root);
1795
2073
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT, {
1796
2074
  acceptNode(node) {
1797
2075
  if (node.nodeType === Node.ELEMENT_NODE) {
@@ -1810,7 +2088,14 @@ function getSubscriberNodes(root) {
1810
2088
  }
1811
2089
  });
1812
2090
  while (walker.nextNode()) {
1813
- 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);
1814
2099
  }
1815
2100
  return subscriberNodes;
1816
2101
  }
@@ -2058,6 +2343,56 @@ function calcWildcardLen(pathInfo, targetPathInfo) {
2058
2343
  return len;
2059
2344
  }
2060
2345
 
2346
+ /**
2347
+ * list/wildcardLevel.ts
2348
+ *
2349
+ * 「パス上のワイルドカード位置」→「listIndex チェーン上の段」の変換を 1 箇所に集める。
2350
+ *
2351
+ * チェーンの長さは常に `Δ + W`(W = そのパスの wildcardCount、Δ = そのスコープの
2352
+ * base 深さ)である。通常の state スコープは Δ=0 なので「位置 i = 段 i」で済んでいたが、
2353
+ * `bind-component` の子スコープがホストの `for` の内側にいる場合は Δ>0 になる
2354
+ * (docs/state-bind-component-nested-for-design.md)。
2355
+ *
2356
+ * そこで**先頭ではなく末尾を基準に数える**。`IListIndex.at()` は負値を受けるので:
2357
+ *
2358
+ * at(i) → at(i - W) // listIndexes[(Δ+W) + (i-W)] = listIndexes[Δ+i]
2359
+ *
2360
+ * Δ=0 のときは両者が同じ要素を指すため、この書き換えは既存スコープに対して
2361
+ * 意味論を変えない。Δ の値を呼び出し側へ配管する必要も無い。
2362
+ */
2363
+ /**
2364
+ * ワイルドカード位置 `wildcardPos`(先頭から 0 始まり)に対応する listIndex を返す。
2365
+ * `wildcardCount` は `wildcardPos` が属するパスのワイルドカード総数。
2366
+ *
2367
+ * 範囲外(`wildcardPos >= wildcardCount`)は null。Δ=0 では `at(pos)` が
2368
+ * チェーン長を超えて null を返していたのと同じ結果になる。**このガードは必須**で、
2369
+ * 落とすと「1 段ループの中で `$2` を読む」が黙って `$1` を返す
2370
+ * (末尾起点では `at(1-1)=at(0)` に化けるため)。
2371
+ */
2372
+ function listIndexAtWildcard(listIndex, wildcardPos, wildcardCount) {
2373
+ if (wildcardPos < 0 || wildcardPos >= wildcardCount) {
2374
+ return null;
2375
+ }
2376
+ return listIndex.at(wildcardPos - wildcardCount);
2377
+ }
2378
+ /**
2379
+ * ユーザーランドへ渡すインデックス列。チェーンの先頭 Δ 段(base)を落とし、
2380
+ * **そのスコープ自身のループ分だけ**にする。
2381
+ *
2382
+ * コンポーネントの作者は、自分がリストの中に置かれるかどうかを知らずに書く。
2383
+ * `$1` や `onClick(event, index)` の意味が設置場所で変わってはいけないので、
2384
+ * Δ は境界の内側に閉じ込める。`$resolve(path, indexes)` は台帳の配列位置で
2385
+ * 引くため、ここで返した列がそのまま往復で使える。
2386
+ */
2387
+ function getScopedIndexes(listIndex, wildcardCount) {
2388
+ // indexes は型上は必須だが、防御的フォールバックを既存挙動として持っている
2389
+ const indexes = listIndex.indexes ?? [];
2390
+ if (indexes.length === wildcardCount) {
2391
+ return indexes;
2392
+ }
2393
+ return indexes.slice(indexes.length - wildcardCount);
2394
+ }
2395
+
2061
2396
  const listIndexByBindingInfoByLoopContext = new WeakMap();
2062
2397
  function getListIndexByBindingInfo(bindingInfo) {
2063
2398
  const loopContext = getLoopContextByNode(bindingInfo.node);
@@ -2079,7 +2414,7 @@ function getListIndexByBindingInfo(bindingInfo) {
2079
2414
  try {
2080
2415
  const wildcardLen = calcWildcardLen(loopContext.pathInfo, bindingInfo.statePathInfo);
2081
2416
  if (wildcardLen > 0) {
2082
- listIndex = loopContext.listIndex.at(wildcardLen - 1);
2417
+ listIndex = listIndexAtWildcard(loopContext.listIndex, wildcardLen - 1, loopContext.pathInfo.wildcardCount);
2083
2418
  }
2084
2419
  return listIndex;
2085
2420
  }
@@ -2599,12 +2934,12 @@ class EventToken extends Token {
2599
2934
  }
2600
2935
  }
2601
2936
 
2602
- const registryByStateElement$2 = new WeakMap();
2937
+ const registryByStateElement$3 = new WeakMap();
2603
2938
  function getOrCreateEventToken(stateElement, name) {
2604
- let registry = registryByStateElement$2.get(stateElement);
2939
+ let registry = registryByStateElement$3.get(stateElement);
2605
2940
  if (typeof registry === "undefined") {
2606
2941
  registry = new Map();
2607
- registryByStateElement$2.set(stateElement, registry);
2942
+ registryByStateElement$3.set(stateElement, registry);
2608
2943
  }
2609
2944
  let token = registry.get(name);
2610
2945
  if (typeof token === "undefined") {
@@ -2614,7 +2949,7 @@ function getOrCreateEventToken(stateElement, name) {
2614
2949
  return token;
2615
2950
  }
2616
2951
  function clearEventTokenRegistry(stateElement) {
2617
- registryByStateElement$2.delete(stateElement);
2952
+ registryByStateElement$3.delete(stateElement);
2618
2953
  }
2619
2954
 
2620
2955
  /**
@@ -2702,7 +3037,8 @@ function attachEventTokenHandler(binding) {
2702
3037
  const loopContext = getLoopContextByNode(element);
2703
3038
  stateElement.createStateAsync("writable", async (state) => {
2704
3039
  const results = state[setLoopContextSymbol](loopContext, () => {
2705
- const indexes = loopContext?.listIndex.indexes ?? [];
3040
+ const indexes = loopContext !== null
3041
+ ? getScopedIndexes(loopContext.listIndex, loopContext.pathInfo.wildcardCount) : [];
2706
3042
  const token = getOrCreateEventToken(stateElement, tokenName);
2707
3043
  return token.emit(state, event, ...indexes);
2708
3044
  });
@@ -2787,7 +3123,8 @@ const stateEventHandlerFunction = (stateName, handlerName, modifiers, statePathI
2787
3123
  const isCommand = isCommandTokenPath(handlerName);
2788
3124
  stateElement.createStateAsync("writable", async (state) => {
2789
3125
  const results = state[setLoopContextSymbol](loopContext, () => {
2790
- const indexes = loopContext?.listIndex.indexes ?? [];
3126
+ const indexes = loopContext !== null
3127
+ ? getScopedIndexes(loopContext.listIndex, loopContext.pathInfo.wildcardCount) : [];
2791
3128
  if (isCommand) {
2792
3129
  // command token を解決して emit。引数はハンドラ呼び出しと同じく (event, ...listIndexes) を透過する。
2793
3130
  const token = state[getByAddressSymbol](createStateAddress(statePathInfo, null));
@@ -3356,33 +3693,482 @@ function detachTwowayEventHandler(binding) {
3356
3693
  }
3357
3694
  }
3358
3695
 
3359
- // framework 自身が detach し明示的に解体(deactivate/unmount)したノード。
3360
- // BindingOwner MutationObserver は削除サブツリー走査でこれらをスキップする。
3361
- //
3362
- // 根拠: 削除時の handleRemovedNode は binding を dispose するだけ(DOM 構造変更も
3363
- // connect-snapshot 依存も無い)で、framework が unmount 経路で既に dispose 済みの
3364
- // content に対しては純粋な冗長走査(forEachInclusive で削除サブツリー全体を歩く)に
3365
- // なる。create(追加)経路は two-way の connect-time snapshot を observer に依存する
3366
- // ため対象外だが、削除は依存が無いため安全に飛ばせる。
3367
- //
3368
- // マークは observer が削除を配送した時点で消費(削除)する。マーク〜配送の間隔は
3369
- // 単一 microtask であり、その間に外部 DOM 変異は割り込めない(framework の drain は
3370
- // 同期)ため、マークは framework 由来の削除にしか一致しない。
3371
- const observerSkipNodes = new WeakSet();
3372
- function markObserverSkipOnRemove(node) {
3373
- observerSkipNodes.add(node);
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);
3374
3704
  }
3375
- // マーク済みなら true を返しつつマークを消費する。未マークなら false。
3376
- function consumeObserverSkipOnRemove(node) {
3377
- if (!observerSkipNodes.has(node)) {
3378
- return false;
3705
+ function getStateElementByWebComponent(webComponent, stateName) {
3706
+ const stateMap = stateElementByWebComponent.get(webComponent);
3707
+ if (!stateMap) {
3708
+ return null;
3379
3709
  }
3380
- observerSkipNodes.delete(node);
3381
- return true;
3710
+ return stateMap.get(stateName) ?? null;
3382
3711
  }
3383
- // framework 自身がマウント(Content.appendTo / mountAfter)したノード。
3384
- // 追加サブツリー走査の実質の仕事は connect-snapshot 待ち(observationPending)の
3385
- // record への配送だけで、record 自体は同期マウント(activateContent → start)で
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
+
3732
+ /**
3733
+ * webComponent/baseListIndex.ts
3734
+ *
3735
+ * mapped な `bind-component` の子スコープが「親スコープのどの行の内側にいるか」。
3736
+ *
3737
+ * ホストのコンポーネント要素が親スコープの `for` の中に置かれている場合、
3738
+ * 子スコープは実際には**ネストしたループの内側**にいる。その深さ Δ を表すのが
3739
+ * base listIndex で、子が作る listIndex はすべてこれを親に持つ。
3740
+ * 結果として `groups[i].children` の listIndex 台帳は arity Δ+1 になり、
3741
+ * これは親が `groups.*.children.*` に対して要求するものと同一になる
3742
+ * (台帳 `listIndexesByList` は配列オブジェクト同一性の WeakMap なので、
3743
+ * 1 つの配列につき 1 組しか持てない。親子で同じ組を使うのが唯一の整合手段)。
3744
+ *
3745
+ * 詳細は docs/state-bind-component-nested-for-design.md。
3746
+ *
3747
+ * **キャッシュしてはいけない。** 行 content はプールで再利用されるため、同じ
3748
+ * コンポーネント要素が別の行に付け替わる。要素をキーにした memo は §1.9 で
3749
+ * 踏んだ罠そのもので、再接続後に古い行を指し続ける。
3750
+ * 通常の state(`hasMappedComponentState` が偽)は最初の 1 行で抜けるので、
3751
+ * ホットパスに walk は載らない。
3752
+ */
3753
+ function getBaseListIndex(stateElement) {
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);
3778
+ }
3779
+ }
3780
+ /** base の段数 Δ。base が無ければ 0。 */
3781
+ function getBaseDepth(stateElement) {
3782
+ return getBaseListIndex(stateElement)?.length ?? 0;
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
+ }
3797
+ /**
3798
+ * リストの行を生成するときの親 listIndex。
3799
+ *
3800
+ * コンテナのアドレスがワイルドカードを持つ(=囲むループがある)ならその listIndex、
3801
+ * 持たない(=そのスコープのトップレベルのリスト)なら base。後者を null のままに
3802
+ * すると、子スコープのリストだけ arity 1 で作られて親の台帳と食い違う。
3803
+ *
3804
+ * **リストの行を作りうる全経路で使うこと。** 既存台帳があれば `createListDiff` は
3805
+ * 再利用するので初期描画では食い違いが見えず、**行を追加したときだけ**
3806
+ * `createListIndex(parentListIndex, i)` が新しい arity で作られて混在する。
3807
+ */
3808
+ function getListParentListIndex(stateElement, containerListIndex) {
3809
+ return containerListIndex ?? getBaseListIndex(stateElement);
3810
+ }
3811
+
3812
+ const innerMappingByElement = new WeakMap();
3813
+ const outerMappingByElement = new WeakMap();
3814
+ const primaryMappingRuleSetByElement = new WeakMap();
3815
+ const primaryBindingByMappingRule = new WeakMap();
3816
+ function createMappingRuleByBinding(innerState, binding) {
3817
+ const innerPathInfo = getPathInfo(binding.propSegments.slice(1).join(DELIMITER));
3818
+ const innerAbsPathInfo = getAbsolutePathInfo(innerState, innerPathInfo);
3819
+ const outerAbsStateAddress = getAbsoluteStateAddressByBinding(binding);
3820
+ const outerAbsPathInfo = outerAbsStateAddress.absolutePathInfo;
3821
+ return { innerAbsPathInfo, outerAbsPathInfo };
3822
+ }
3823
+ function buildPrimaryMappingRule(webComponent, stateName, bindings) {
3824
+ if (bindings.length === 0) {
3825
+ return;
3826
+ }
3827
+ const innerState = getStateElementByWebComponent(webComponent, stateName);
3828
+ if (innerState === null) {
3829
+ raiseError('State element not found for web component.');
3830
+ }
3831
+ const innerMappingRule = new Map();
3832
+ const outerMappingRule = new Map();
3833
+ for (const binding of bindings) {
3834
+ const mappingRule = createMappingRuleByBinding(innerState, binding);
3835
+ let primaryMappingRuleSet = primaryMappingRuleSetByElement.get(webComponent);
3836
+ if (typeof primaryMappingRuleSet === 'undefined') {
3837
+ primaryMappingRuleSetByElement.set(webComponent, new Set([mappingRule]));
3838
+ }
3839
+ else {
3840
+ primaryMappingRuleSet.add(mappingRule);
3841
+ }
3842
+ const innerAbsPathInfo = mappingRule.innerAbsPathInfo;
3843
+ const outerAbsPathInfo = mappingRule.outerAbsPathInfo;
3844
+ primaryBindingByMappingRule.set(mappingRule, binding);
3845
+ innerMappingRule.set(innerAbsPathInfo, outerAbsPathInfo);
3846
+ outerMappingRule.set(outerAbsPathInfo, innerAbsPathInfo);
3847
+ // 1 つ外のスコープへのリンク。Δ の境界越え合成(§1.12)が引く。
3848
+ // プライマリ規則はすべて同じホスト要素の data-wcs 由来なので、どの規則から
3849
+ // 採っても同じスコープを指す。
3850
+ setOuterStateElementByWebComponent(webComponent, outerAbsPathInfo.stateElement);
3851
+ }
3852
+ innerMappingByElement.set(webComponent, innerMappingRule);
3853
+ outerMappingByElement.set(webComponent, outerMappingRule);
3854
+ }
3855
+ /**
3856
+ * プライマリ規則だけを残して、遅延導出された派生規則の memo を捨てる(§1.9)。
3857
+ *
3858
+ * 派生規則は導出と同時に「親スコープの購読者」を立てる。その購読者は子の切断で
3859
+ * teardown されるが、memo は要素をキーに残り続けるため、再接続後は**導出が二度と
3860
+ * 走らず購読者も張り直されない** — 親がサブパスへ書いても子に届かなくなる。
3861
+ * リスト行の content 再利用で実際に踏む(行を差し替えると、その行の子だけが
3862
+ * 以後の行フィールド書き込みを受け取れない)。
3863
+ *
3864
+ * `buildPrimaryMappingRule` は再バインド時に同じことをしている(台帳を作り直す)。
3865
+ * 再接続では bindWebComponent が走らないので、ここで同じ状態に戻す。
3866
+ */
3867
+ function resetDerivedMappingRules(webComponent) {
3868
+ const primaryMappingRuleSet = primaryMappingRuleSetByElement.get(webComponent);
3869
+ if (typeof primaryMappingRuleSet === 'undefined') {
3870
+ return;
3871
+ }
3872
+ const innerMappingRule = new Map();
3873
+ const outerMappingRule = new Map();
3874
+ for (const rule of primaryMappingRuleSet) {
3875
+ innerMappingRule.set(rule.innerAbsPathInfo, rule.outerAbsPathInfo);
3876
+ outerMappingRule.set(rule.outerAbsPathInfo, rule.innerAbsPathInfo);
3877
+ }
3878
+ innerMappingByElement.set(webComponent, innerMappingRule);
3879
+ outerMappingByElement.set(webComponent, outerMappingRule);
3880
+ }
3881
+ /**
3882
+ * このコンポーネントに張られたプライマリ規則の**内側パス**を列挙する。
3883
+ *
3884
+ * 切断 → 再接続を跨いだ子(行 content の再利用で起きる)は、切断中に親で起きた変更の
3885
+ * 通知を受け取れていない。再接続時に「束ねているパスを読み直せ」と撃つための入力で、
3886
+ * 何が変わったかは分からないのでプライマリ規則の粒度で丸ごと読み直す(§1.9)。
3887
+ */
3888
+ function getPrimaryInnerPaths(webComponent) {
3889
+ const primaryMappingRuleSet = primaryMappingRuleSetByElement.get(webComponent);
3890
+ if (typeof primaryMappingRuleSet === 'undefined') {
3891
+ return [];
3892
+ }
3893
+ const paths = [];
3894
+ for (const rule of primaryMappingRuleSet) {
3895
+ paths.push(rule.innerAbsPathInfo.pathInfo.path);
3896
+ }
3897
+ return paths;
3898
+ }
3899
+ /**
3900
+ * 内側のパスを外側のパスへ翻訳する。規則が無ければプライマリ規則から導出する。
3901
+ *
3902
+ * `registerSubscriber` は導出に**副作用を持たせるか**の切り替え。既定(子の read /
3903
+ * write からの呼び出し)では導出した規則を台帳に memo し、対応するバインディングを
3904
+ * 親スコープの購読者として登録する。`false` を渡すと**参照専用**になり、台帳にも
3905
+ * 購読者にも触れない。
3906
+ *
3907
+ * 参照専用が要るのは、バインディング登録の最中(`BindingSession.registerAddress` →
3908
+ * `setPathInfo` / 行の相乗り登録)に翻訳だけしたい場合。ここで購読者登録まで走ると
3909
+ * `session.initialize` がセッション操作の内側から再入する。
3910
+ *
3911
+ * 参照専用の結果を台帳に memo しないのは、後から来た**本物の read が memo に当たって
3912
+ * 購読者登録を永久に飛ばしてしまう**ため。導出のやり直しは初回だけで、以降は本物の
3913
+ * read が張った memo に当たる(行 2 本目以降の登録は先頭行の read が埋めた台帳を引く)。
3914
+ */
3915
+ function getOuterAbsolutePathInfo(webComponent, innerAbsPathInfo, registerSubscriber = true) {
3916
+ let innerMapping = innerMappingByElement.get(webComponent);
3917
+ if (typeof innerMapping === 'undefined') {
3918
+ innerMapping = new Map();
3919
+ innerMappingByElement.set(webComponent, innerMapping);
3920
+ }
3921
+ if (innerMapping.has(innerAbsPathInfo)) {
3922
+ return innerMapping.get(innerAbsPathInfo);
3923
+ }
3924
+ let outerMapping = outerMappingByElement.get(webComponent);
3925
+ if (typeof outerMapping === 'undefined') {
3926
+ outerMapping = new Map();
3927
+ outerMappingByElement.set(webComponent, outerMapping);
3928
+ }
3929
+ // 内側からのアクセスの場合、ルールがなければプライマリルールから新たにルールとバインディングを生成する
3930
+ const primaryMappingRuleSet = primaryMappingRuleSetByElement.get(webComponent);
3931
+ if (typeof primaryMappingRuleSet === 'undefined') {
3932
+ // マッピングルールが存在しない場合はnullを返し、ローカル状態へのフォールバックを許可する
3933
+ return null;
3934
+ }
3935
+ let primaryMappingRule = null;
3936
+ for (const currentPrimaryMappingRule of primaryMappingRuleSet) {
3937
+ // innerPathInfoがprimaryMappingRuleのinnerPathInfoを包含しているか
3938
+ if (!innerAbsPathInfo.pathInfo.cumulativePathInfoSet.has(currentPrimaryMappingRule.innerAbsPathInfo.pathInfo)) {
3939
+ continue;
3940
+ }
3941
+ if (currentPrimaryMappingRule.innerAbsPathInfo.pathInfo.segments.length === innerAbsPathInfo.pathInfo.segments.length) {
3942
+ raiseError('Duplicate mapping rule for web component.');
3943
+ }
3944
+ primaryMappingRule = currentPrimaryMappingRule;
3945
+ break;
3946
+ }
3947
+ if (primaryMappingRule === null) {
3948
+ // マッピングルールに一致しない場合はnullを返し、ローカル状態へのフォールバックを許可する
3949
+ return null;
3950
+ }
3951
+ // マッチした残りのパスをouterPathInfoに付与して新たなルールを生成
3952
+ const primaryBinding = primaryBindingByMappingRule.get(primaryMappingRule);
3953
+ /* c8 ignore start */
3954
+ if (typeof primaryBinding === 'undefined') {
3955
+ raiseError('Binding not found for primary mapping rule on web component.');
3956
+ }
3957
+ /* c8 ignore stop */
3958
+ const outerRemainingSegments = innerAbsPathInfo.pathInfo.segments.slice(primaryMappingRule.innerAbsPathInfo.pathInfo.segments.length);
3959
+ const outerSegments = primaryMappingRule.outerAbsPathInfo.pathInfo.segments.concat(outerRemainingSegments);
3960
+ const outerPathInfo = getPathInfo(outerSegments.join(DELIMITER));
3961
+ const rootNode = webComponent.getRootNode();
3962
+ const outerStateElement = getStateElementByName(rootNode, primaryBinding.stateName);
3963
+ if (outerStateElement === null) {
3964
+ raiseError(`State element with name "${primaryBinding.stateName}" not found for web component.`);
3965
+ }
3966
+ const outerAbsPathInfo = getAbsolutePathInfo(outerStateElement, outerPathInfo);
3967
+ if (!registerSubscriber) {
3968
+ // 参照専用: 台帳にも購読者にも触れず、翻訳結果だけ返す
3969
+ return outerAbsPathInfo;
3970
+ }
3971
+ innerMapping.set(innerAbsPathInfo, outerAbsPathInfo);
3972
+ outerMapping.set(outerAbsPathInfo, innerAbsPathInfo);
3973
+ // ルールに対応するバインディングを生成し、親スコープの購読者として登録する。
3974
+ //
3975
+ // 子が読んだサブパス(inner "user.name" = outer "person.name")は、子が
3976
+ // そのパスに関心を宣言したということ。親がそこへ書いたときに子へ再読込通知が
3977
+ // 届くよう、プライマリと同じ形のバインディングを立てて絶対アドレス台帳に載せる。
3978
+ //
3979
+ // propSegments は stateProp(プライマリの先頭セグメント)を保つ必要がある。
3980
+ // 適用側は先頭セグメントで束ね先の state 要素を引く(apply/applyChangeToWebComponent.ts)
3981
+ // ため、inner パスだけにすると通知先を解決できない。
3982
+ //
3983
+ // 登録はプライマリを所有する BindingSession 経由で行う。台帳登録・teardown・
3984
+ // ノード削除時の破棄(MutationObserver 配送)が既存のライフサイクルにそのまま乗り、
3985
+ // 絶対アドレス台帳のエントリが component を強参照したまま残るのを防ぐ。
3986
+ // node 台帳(addBindingByNode)へは積まない — stateProp を保った結果、
3987
+ // 再バインド時に buildPrimaryMappingRule のプライマリ抽出フィルタへ混入するため。
3988
+ const propSegments = [primaryBinding.propSegments[0], ...innerAbsPathInfo.pathInfo.segments];
3989
+ const newBinding = {
3990
+ ...primaryBinding,
3991
+ propName: propSegments.join(DELIMITER),
3992
+ propSegments,
3993
+ statePathName: outerAbsPathInfo.pathInfo.path,
3994
+ statePathInfo: outerAbsPathInfo.pathInfo,
3995
+ };
3996
+ // 登録できないケースは登録だけ諦める。ここは翻訳が本務なので read を落とさない
3997
+ // = この機構が入る前と同じ挙動に留める(debug 時のみ観測可能にする)。2 通りある。
3998
+ //
3999
+ // (a) セッションが引けない: 内部的な想定外(プライマリは親スコープの収集で必ず
4000
+ // session.initialize を通っている)。
4001
+ // (b) 導出した outer パスがワイルドカードを含むのに listIndex が決まらない:
4002
+ // 子が配列マッピングの上で for を回している場合(規則 state.items: rows に対し
4003
+ // 子の行が items.*.name を読む → outer は rows.*.name)。派生バインディングの
4004
+ // node は親スコープにあるコンポーネント要素で、ループは子の Shadow 内なので
4005
+ // コンポーネントからは行を特定できない = この 1 本では行を表現できない。
4006
+ // ここで登録を試みると getAbsoluteStateAddressByBinding が raiseError する。
4007
+ // この形の親→子配送は派生バインディングではなく、子の行バインディング自身を
4008
+ // 親のパターン台帳((absolutePathInfo, listIndex))へ相乗りさせて成立させる
4009
+ // (BindingSession.registerAddress / webComponent/outerListPath.ts、§1.8)。
4010
+ const skipRegistration = (reason) => {
4011
+ if (config.debug) {
4012
+ console.warn(`parent→child notification for "${outerAbsPathInfo.pathInfo.path}" is not registered: ${reason}.`, { webComponent, primaryBinding });
4013
+ }
4014
+ };
4015
+ const session = getBindingSession(primaryBinding);
4016
+ if (session === null) {
4017
+ skipRegistration('no binding session for the primary mapping rule');
4018
+ return outerAbsPathInfo;
4019
+ }
4020
+ if (outerAbsPathInfo.pathInfo.wildcardCount > 0 && getListIndexByBindingInfo(newBinding) === null) {
4021
+ skipRegistration('the derived outer path is a wildcard path but no list index resolves from the component');
4022
+ return outerAbsPathInfo;
4023
+ }
4024
+ // 戻り値(初期 apply 対象)は使わない。この導出は子の read の最中に起きるので、
4025
+ // 子は既に最新値を読んでおり、ここでの再通知は冗長かつ再入になる。
4026
+ session.initialize([newBinding], { registerAddress: true });
4027
+ return outerAbsPathInfo;
4028
+ }
4029
+
4030
+ /**
4031
+ * webComponent/outerListPath.ts
4032
+ *
4033
+ * mapped な `bind-component` の子スコープが宣言した「リスト」を、値の正本を持つ
4034
+ * 親スコープ側へ伝えるための翻訳ヘルパ
4035
+ * (docs/architecture-hardening/15-state-component-mechanism-consistency.md §1.8)。
4036
+ *
4037
+ * 子の `for: items` が登録するのは子の state 要素の listPaths / elementPaths だけで、
4038
+ * 配列の実体を持つ親 state 要素は `rows` がリストであることを知らないままだった。
4039
+ * 親が `rows` を書いたときの依存 walk は `rows → rows.*` の静的子展開を
4040
+ * listPaths で判定するため、未登録だと行ごとの listIndex に展開されず
4041
+ * 「listIndex null のワイルドカードアドレス」1 本に潰れる(誰にも届かない)。
4042
+ * `rows.*` が elementPaths に無いと、行そのものへの代入(swap イディオム)も
4043
+ * listIndex 台帳の付け替えを伴わない素の代入に落ちる。
4044
+ */
4045
+ /**
4046
+ * 子スコープの `for:` パスに対応する親スコープのパスへ「これはリストだ」を伝える。
4047
+ * マッピング規則が無い(plain なコンポーネント / ローカル state のリスト)場合は何もしない。
4048
+ *
4049
+ * 親がさらに別コンポーネントの mapped state であれば、その親の `setPathInfo` から
4050
+ * 再びここへ入って外向きに伝播する。各段で必ず外側の state 要素へ進むので停止する。
4051
+ */
4052
+ function propagateListPathToOuterState(innerStateElement, innerPath) {
4053
+ const outerAbsPathInfo = resolveOuterAbsolutePathInfo(innerStateElement, getPathInfo(innerPath));
4054
+ if (outerAbsPathInfo === null || outerAbsPathInfo.stateElement === innerStateElement) {
4055
+ return;
4056
+ }
4057
+ outerAbsPathInfo.stateElement.setPathInfo(outerAbsPathInfo.pathInfo.path, "for");
4058
+ }
4059
+ /**
4060
+ * 子スコープのリスト行パス(`items.*.name`)に対応する親スコープの絶対パス情報を返す。
4061
+ *
4062
+ * 呼び出し側(`BindingSession.registerAddress`)は、行バインディングを**この外側パスと
4063
+ * 子スコープの listIndex の組**で親のパターン台帳に相乗りさせる。したがって成立条件は
4064
+ * 「子の listIndex が外側パスの段数をちょうど満たすこと」=
4065
+ * `Δ + innerW === outerW`(Δ = base 深さ)。
4066
+ *
4067
+ * - コンポーネントが親の `for` の外(Δ=0): `outerW === innerW`(§1.8)
4068
+ * - コンポーネントが親の `for` の中(Δ>0): 子の listIndex は base を親に持つので
4069
+ * チェーン長が Δ+innerW になり、そのまま外側パスの段数と一致する
4070
+ * (docs/state-bind-component-nested-for-design.md)
4071
+ */
4072
+ function getOuterRowPathInfo(innerStateElement, innerPathInfo) {
4073
+ if (innerPathInfo.wildcardCount === 0) {
4074
+ return null;
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) {
4119
+ const outerAbsPathInfo = resolveOuterAbsolutePathInfo(innerStateElement, innerPathInfo);
4120
+ if (outerAbsPathInfo === null || outerAbsPathInfo.stateElement === innerStateElement) {
4121
+ return null;
4122
+ }
4123
+ const innerArity = getScopeArity(innerStateElement, innerPathInfo);
4124
+ const outerArity = getScopeArity(outerAbsPathInfo.stateElement, outerAbsPathInfo.pathInfo);
4125
+ if (innerArity !== outerArity) {
4126
+ return null;
4127
+ }
4128
+ return outerAbsPathInfo;
4129
+ }
4130
+ function resolveOuterAbsolutePathInfo(innerStateElement, innerPathInfo) {
4131
+ if (innerStateElement.hasMappedComponentState !== true) {
4132
+ return null;
4133
+ }
4134
+ const component = innerStateElement.boundComponent;
4135
+ if (component == null) {
4136
+ return null;
4137
+ }
4138
+ const innerAbsPathInfo = getAbsolutePathInfo(innerStateElement, innerPathInfo);
4139
+ // 参照専用で引く。ここはバインディング登録の最中(registerAddress → setPathInfo /
4140
+ // 行の相乗り登録)から呼ばれるので、翻訳のついでに購読者登録まで走らせると
4141
+ // `session.initialize` がセッション操作の内側から再入する。
4142
+ return getOuterAbsolutePathInfo(component, innerAbsPathInfo, false);
4143
+ }
4144
+
4145
+ // framework 自身が detach し明示的に解体(deactivate/unmount)したノード。
4146
+ // BindingOwner の MutationObserver は削除サブツリー走査でこれらをスキップする。
4147
+ //
4148
+ // 根拠: 削除時の handleRemovedNode は binding を dispose するだけ(DOM 構造変更も
4149
+ // connect-snapshot 依存も無い)で、framework が unmount 経路で既に dispose 済みの
4150
+ // content に対しては純粋な冗長走査(forEachInclusive で削除サブツリー全体を歩く)に
4151
+ // なる。create(追加)経路は two-way の connect-time snapshot を observer に依存する
4152
+ // ため対象外だが、削除は依存が無いため安全に飛ばせる。
4153
+ //
4154
+ // マークは observer が削除を配送した時点で消費(削除)する。マーク〜配送の間隔は
4155
+ // 単一 microtask であり、その間に外部 DOM 変異は割り込めない(framework の drain は
4156
+ // 同期)ため、マークは framework 由来の削除にしか一致しない。
4157
+ const observerSkipNodes = new WeakSet();
4158
+ function markObserverSkipOnRemove(node) {
4159
+ observerSkipNodes.add(node);
4160
+ }
4161
+ // マーク済みなら true を返しつつマークを消費する。未マークなら false。
4162
+ function consumeObserverSkipOnRemove(node) {
4163
+ if (!observerSkipNodes.has(node)) {
4164
+ return false;
4165
+ }
4166
+ observerSkipNodes.delete(node);
4167
+ return true;
4168
+ }
4169
+ // framework 自身がマウント(Content.appendTo / mountAfter)したノード。
4170
+ // 追加サブツリー走査の実質の仕事は connect-snapshot 待ち(observationPending)の
4171
+ // record への配送だけで、record 自体は同期マウント(activateContent → start)で
3386
4172
  // observer flush より先に active 済み。よって待ちがグローバルに 1 つも無ければ
3387
4173
  // 追加側走査も冗長であり丸ごとスキップできる(削除側スキップの対称形)。
3388
4174
  // マーク〜配送が単一 microtask で外部変異が割り込めない前提も削除側と同じ。
@@ -4154,6 +4940,8 @@ class BindingSession {
4154
4940
  address: null,
4155
4941
  patternPathInfo: null,
4156
4942
  patternListIndex: null,
4943
+ outerPatternPathInfo: null,
4944
+ outerPatternPathInfosRest: null,
4157
4945
  pendingDefinitions: 0,
4158
4946
  initialPolicy: slot.policy,
4159
4947
  resolvedAuthority: slot.authority,
@@ -4269,6 +5057,8 @@ class BindingSession {
4269
5057
  address: null,
4270
5058
  patternPathInfo: null,
4271
5059
  patternListIndex: null,
5060
+ outerPatternPathInfo: null,
5061
+ outerPatternPathInfosRest: null,
4272
5062
  pendingDefinitions: 0,
4273
5063
  initialPolicy: null,
4274
5064
  resolvedAuthority: null,
@@ -4477,6 +5267,26 @@ class BindingSession {
4477
5267
  addBindingByPattern(absolutePathInfo, listIndex, binding);
4478
5268
  record.patternPathInfo = absolutePathInfo;
4479
5269
  record.patternListIndex = listIndex;
5270
+ // mapped な bind-component の子スコープが回している行は、値の正本が親 state に
5271
+ // ある。親が行へ書いたときの enqueue は親の絶対パス情報で起きるので、同じ
5272
+ // listIndex(親子で共有されている)で親側のパターン台帳にも購読者として載せる。
5273
+ // これが無いと親起点の行フィールド書き込みが子に一切届かない(§1.8)。
5274
+ const outerPathInfo = getOuterRowPathInfo(stateElement, binding.statePathInfo);
5275
+ if (outerPathInfo !== null) {
5276
+ addBindingByPattern(outerPathInfo, listIndex, binding);
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
+ }
5289
+ }
4480
5290
  }
4481
5291
  else {
4482
5292
  const address = getAbsoluteStateAddressByBinding(binding, knownRoot);
@@ -4533,6 +5343,29 @@ class BindingSession {
4533
5343
  }
4534
5344
  }
4535
5345
  else if (record.patternListIndex !== null) {
5346
+ // 親スコープへの相乗り分は独立した資源なので、子側の解除が失敗しても取り残さない
5347
+ if (record.outerPatternPathInfo !== null) {
5348
+ try {
5349
+ removeBindingByPattern(record.outerPatternPathInfo, record.patternListIndex, binding);
5350
+ }
5351
+ catch {
5352
+ // Cleanup is best-effort.
5353
+ }
5354
+ record.outerPatternPathInfo = null;
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
+ }
4536
5369
  try {
4537
5370
  removeBindingByPattern(record.patternPathInfo, record.patternListIndex, binding);
4538
5371
  record.patternPathInfo = null;
@@ -4590,21 +5423,35 @@ function getBindingSession(binding) {
4590
5423
  return recordByBinding.get(binding)?.session ?? null;
4591
5424
  }
4592
5425
 
4593
- const completeByStateElementByWebComponent = new WeakMap();
4594
- function markWebComponentAsComplete(webComponent, stateElement) {
4595
- let completeByStateElement = completeByStateElementByWebComponent.get(webComponent);
4596
- if (!completeByStateElement) {
4597
- completeByStateElement = new WeakMap();
4598
- completeByStateElementByWebComponent.set(webComponent, completeByStateElement);
5426
+ /**
5427
+ * `bind-component` の配線が完了した (webComponent, stateProp) の台帳。
5428
+ *
5429
+ * 完了前は state プロパティがまだ素のオブジェクトなので、親からの適用は
5430
+ * `applyChangeToProperty` がそこへ値を積み、`bindWebComponent` が melt して取り込む。
5431
+ * 完了後は公開プロパティが outerState proxy に差し替わっているため、親からの適用は
5432
+ * 値を運ばない内部通知チャネル(`applyChangeToWebComponent`)へ切り替わる。
5433
+ * その切り替え判定がこの台帳。
5434
+ *
5435
+ * キーは「state プロパティ名」であって state 要素ではない。完了はプロパティ単位の
5436
+ * 事実(`defineProperty(component, stateProp, ...)` が済んだか)であり、
5437
+ * 1 つの要素に複数の state プロパティを束ねられる以上、粒度もプロパティ単位が正しい。
5438
+ * 以前は内側の `IStateElement` をキーにしていたが、照会側(apply/applyChange.ts)が
5439
+ * 手にしているのは *親スコープ* の `IStateElement` であり、どちらも同じ型なので
5440
+ * TypeScript が取り違えを検出できず、判定が恒久的に false になっていた
5441
+ * (=親 state 起点の変更が子コンポーネントへ届かない。
5442
+ * docs/architecture-hardening/15-state-component-mechanism-consistency.md §1.7)。
5443
+ */
5444
+ const completedStatePropsByWebComponent = new WeakMap();
5445
+ function markWebComponentAsComplete(webComponent, stateProp) {
5446
+ let completedStateProps = completedStatePropsByWebComponent.get(webComponent);
5447
+ if (!completedStateProps) {
5448
+ completedStateProps = new Set();
5449
+ completedStatePropsByWebComponent.set(webComponent, completedStateProps);
4599
5450
  }
4600
- completeByStateElement.set(stateElement, true);
5451
+ completedStateProps.add(stateProp);
4601
5452
  }
4602
- function isWebComponentComplete(webComponent, stateElement) {
4603
- const completeByStateElement = completeByStateElementByWebComponent.get(webComponent);
4604
- if (!completeByStateElement) {
4605
- return false;
4606
- }
4607
- return completeByStateElement.get(stateElement) === true;
5453
+ function isWebComponentComplete(webComponent, stateProp) {
5454
+ return completedStatePropsByWebComponent.get(webComponent)?.has(stateProp) === true;
4608
5455
  }
4609
5456
 
4610
5457
  function applyChangeToAttribute(binding, _context, newValue) {
@@ -5783,7 +6630,8 @@ function applyChangeToFor(bindingInfo, context, newValue) {
5783
6630
  const listIndex = getListIndexByBindingInfo(bindingInfo);
5784
6631
  const absAddress = getAbsoluteStateAddressByBinding(bindingInfo);
5785
6632
  const lastValue = getLastListValueByAbsoluteStateAddress(absAddress);
5786
- const diff = createListDiff(listIndex, lastValue, newValue);
6633
+ // 子スコープのトップレベルのリストは base を親に持つ(webComponent/baseListIndex.ts)
6634
+ const diff = createListDiff(getListParentListIndex(context.stateElement, listIndex), lastValue, newValue);
5787
6635
  context.newListValueByAbsAddress.set(absAddress, Array.isArray(newValue) ? newValue : []);
5788
6636
  const fullDelete = Array.isArray(lastValue)
5789
6637
  && lastValue.length === diff.deleteIndexSet.size
@@ -5916,6 +6764,10 @@ function applyChangeToFor(bindingInfo, context, newValue) {
5916
6764
  if (content === null) {
5917
6765
  raiseError(`Content not found for ListIndex: ${index.index} at path "${listPathInfo.path}"`);
5918
6766
  }
6767
+ // 祖先の unmount(if の非表示など)で解体された行は、ここで物理的に
6768
+ // 戻されるだけでは binding が dispose 済みのまま復活しない。位置合わせの
6769
+ // 前に判定しておき(mountAfter が mounted を立てる)、戻した後に再活性化する。
6770
+ const unmountedByAncestor = !content.mounted;
5919
6771
  // Stable contents are already in correct relative order — but only
5920
6772
  // trust that after physical verification (see isPhysicallyAfter).
5921
6773
  // Contents out of order (and everything unverifiable) settle via the
@@ -5925,6 +6777,16 @@ function applyChangeToFor(bindingInfo, context, newValue) {
5925
6777
  if (!stable && lastNode.nextSibling !== content.firstNode) {
5926
6778
  content.mountAfter(lastNode);
5927
6779
  }
6780
+ if (unmountedByAncestor) {
6781
+ // 再活性化しないと、行の同一性が保たれる更新が以後すべて無視される
6782
+ // (docs/state-deactivated-content-stale-update.md)。activate は
6783
+ // disposed record の再構築を含むので、プール再利用と同じ経路で戻る。
6784
+ const revivedContent = content;
6785
+ const stateAddress = createStateAddress(elementPathInfo, index);
6786
+ loopContextStack.createLoopContext(stateAddress, (loopContext) => {
6787
+ activateContent(revivedContent, loopContext, context);
6788
+ });
6789
+ }
5928
6790
  }
5929
6791
  lastNode = content.lastNode || lastNode;
5930
6792
  if (typeof contentMap === 'undefined') {
@@ -6304,18 +7166,58 @@ function applyChangeToText(binding, _context, newValue) {
6304
7166
  }
6305
7167
  }
6306
7168
 
6307
- function applyChangeToWebComponent(binding, _context, newValue) {
6308
- const element = binding.node;
7169
+ /**
7170
+ * state → `bind-component` 済みコンポーネントの再読込通知(内部チャネル)。
7171
+ *
7172
+ * 値そのものは運ばない。バインドされたパスの正本は親 state 側にあり、子は
7173
+ * innerState proxy のマッピング経由で親を読みに行くため、必要なのは
7174
+ * 「そのパスを読み直せ」という通知だけ。
7175
+ *
7176
+ * 以前は `element[stateProp][path] = value` と、コンポーネントの公開プロパティを
7177
+ * 経由してこの通知を送っていた。受け側の proxy が値を捨てて `$postUpdate` を呼ぶ
7178
+ * 作りだったのはそのためだが、同じ proxy が `this.state` として作者にも見えていたので、
7179
+ * 公開 API 側の書き込みまで no-op になっていた。通知はここで state element を直接
7180
+ * 引く形に分離し、公開 proxy は素通し意味論に統一した
7181
+ * (docs/architecture-hardening/15-state-component-mechanism-consistency.md §1.1 / G1)。
7182
+ *
7183
+ * この関数が選ばれるのは `isWebComponentComplete` が真のときだけなので
7184
+ * (apply/applyChange.ts)、`bindWebComponent` は完了済み = state element は登録済み。
7185
+ * ただし**登録済みと使用可能は別**で、切断済みの state element が台帳に残っている
7186
+ * 窓がある(§1.9)。下の使用可能判定を参照。
7187
+ */
7188
+ function applyChangeToWebComponent(binding, _context, _newValue) {
7189
+ const element = binding.node;
6309
7190
  const propSegments = binding.propSegments;
6310
7191
  if (propSegments.length <= 1) {
6311
- raiseError(`Invalid propSegments for web component binding: ${propSegments.join(".")}`);
7192
+ raiseError(`Invalid propSegments for web component binding: ${propSegments.join(DELIMITER)}`);
6312
7193
  }
6313
7194
  const [firstSegment, ...restSegments] = propSegments;
6314
- const subObject = element[firstSegment];
6315
- if (typeof subObject === "undefined") {
6316
- raiseError(`Property "${firstSegment}" not found on web component.`);
7195
+ const innerStateElement = getStateElementByWebComponent(element, firstSegment);
7196
+ if (innerStateElement === null) {
7197
+ raiseError(`State element not bound to "${firstSegment}" on web component.`);
7198
+ }
7199
+ // 切断済みの state element には送らない。
7200
+ //
7201
+ // リスト行にコンポーネントがあるとき、行の再生成では **DOM に戻る前に** apply が走る。
7202
+ // 行の content(と中のコンポーネント要素)は再利用されるので、要素をキーにした
7203
+ // 台帳 `stateElementByWebComponent` は前回の state element を指したままで、
7204
+ // その要素は既に切断されている(`rootNode` を失っている)。そこへ `createState` すると
7205
+ // raiseError し、**updater の drain も applyChangeToFor の行ループも例外を捕まえない**ため、
7206
+ // 1 つの行が同じバッチの残り全部を道連れにする — 実測では for が空になったまま、
7207
+ // 以後どんな更新でも復帰しなくなる(§1.9)。
7208
+ //
7209
+ // ここは値を運ばない再読込通知なので、切断中の子に送る意味がそもそも無い。
7210
+ // 子が DOM に戻れば、子のバインディングが innerState 経由で親をライブ読みするため
7211
+ // 現在値はそのとき正しく入る(初期配送と同じ経路)。よって no-op で落として良い。
7212
+ if (innerStateElement.hasRootNode === false) {
7213
+ if (config.debug) {
7214
+ console.debug(`[@wcstack/state] skipped parent→child notification for a disconnected state element on <${element.tagName.toLowerCase()}>.`, { element, stateProp: firstSegment, path: restSegments.join(DELIMITER) });
7215
+ }
7216
+ return;
6317
7217
  }
6318
- subObject[restSegments.join(".")] = newValue;
7218
+ innerStateElement.createState("readonly", (state) => {
7219
+ state.$postUpdate(restSegments.join(DELIMITER));
7220
+ });
6319
7221
  }
6320
7222
 
6321
7223
  // indexName ... $1, $2, ...
@@ -6327,7 +7229,7 @@ function getIndexValueByLoopContext(loopContext, indexName) {
6327
7229
  if (typeof indexPos === "undefined") {
6328
7230
  raiseError(`Invalid index name: ${indexName}`);
6329
7231
  }
6330
- const listIndex = loopContext.listIndex.at(indexPos);
7232
+ const listIndex = listIndexAtWildcard(loopContext.listIndex, indexPos, loopContext.pathInfo.wildcardCount);
6331
7233
  if (listIndex === null) {
6332
7234
  raiseError(`Index not found at position ${indexPos} for loopContext:`);
6333
7235
  }
@@ -6417,6 +7319,23 @@ const deferredSelectBindingByBinding = new WeakMap();
6417
7319
  // 一度確認したら以後は不変(define は不可逆)なので apply 毎の getCustomElement /
6418
7320
  // registry 照会を省略できる。scoped registry を導入する場合はこの不可逆前提を再検討。
6419
7321
  const definedApplyVerifiedByBinding = new WeakMap();
7322
+ /**
7323
+ * このバインディングを「値を運ばない親→子の再読込通知」(applyChangeToWebComponent)へ
7324
+ * 回してよいか。
7325
+ *
7326
+ * 長さ 1 の propSegments を除くのが要点。`data-wcs="state: user"` のように
7327
+ * bind-component の stateProp をそのままプロパティ名に書いた形は、完了台帳のキーが
7328
+ * stateProp 名になった以上ゲートを通ってしまうが、applyChangeToWebComponent は
7329
+ * 「先頭セグメント=束ね先の state 要素、残り=子側のパス」を前提にしており
7330
+ * 残余が空だと raiseError する。updater の drain は例外を捕まえないので、
7331
+ * 誤設定タグ 1 つが同じバッチの無関係な更新まで巻き添えにしてしまう。
7332
+ * ここで弾いておけば従来どおり applyChangeToProperty に落ち、挙動は変わらない
7333
+ * (getter だけの公開プロパティへの代入が握り潰される = 無言の no-op)。
7334
+ */
7335
+ function isWebComponentCompleteForBinding(binding) {
7336
+ return binding.propSegments.length > 1
7337
+ && isWebComponentComplete(binding.replaceNode, binding.propSegments[0]);
7338
+ }
6420
7339
  function _applyChange(binding, context) {
6421
7340
  const value = getValue(context.state, binding);
6422
7341
  const filteredValue = getFilteredValue(value, binding.outFilters);
@@ -6430,7 +7349,7 @@ function _applyChange(binding, context) {
6430
7349
  return;
6431
7350
  }
6432
7351
  if (fnByBinding.has(binding)) {
6433
- if (isWebComponentComplete(binding.replaceNode, context.stateElement)) {
7352
+ if (isWebComponentCompleteForBinding(binding)) {
6434
7353
  fn = applyChangeToWebComponent;
6435
7354
  fnByBinding.set(binding, fn); // 確定したのでキャッシュ
6436
7355
  }
@@ -6448,7 +7367,7 @@ function _applyChange(binding, context) {
6448
7367
  if (typeof fn === 'undefined') {
6449
7368
  const customTag = getCustomElement(binding.replaceNode);
6450
7369
  if (customTag) {
6451
- if (isWebComponentComplete(binding.replaceNode, context.stateElement)) {
7370
+ if (isWebComponentCompleteForBinding(binding)) {
6452
7371
  fn = applyChangeToWebComponent;
6453
7372
  fnByBinding.set(binding, fn); // 確定したのでキャッシュ
6454
7373
  }
@@ -6949,9 +7868,17 @@ function _getFragmentInfo(rootNode, fragment, parseBindingTextResult, forPath) {
6949
7868
  }
6950
7869
  function collectStructuralFragments(rootNode, walkRoot, forPath) {
6951
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);
6952
7876
  const walker = document.createTreeWalker(walkRoot, NodeFilter.SHOW_ELEMENT, {
6953
7877
  acceptNode(node) {
6954
7878
  const element = node;
7879
+ if (nestedComponents.length > 0 && nestedComponents.indexOf(element) !== -1) {
7880
+ return NodeFilter.FILTER_REJECT;
7881
+ }
6955
7882
  if (element.tagName.toLowerCase() === 'template') {
6956
7883
  const bindText = element.getAttribute(config.bindAttributeName) || '';
6957
7884
  if (bindText.length > 0) {
@@ -7072,6 +7999,13 @@ async function waitForStateInitialize(root) {
7072
7999
  const promises = [];
7073
8000
  await customElements.whenDefined(config.tagNames.state);
7074
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
+ }
7075
8009
  const stateElement = element;
7076
8010
  promises.push(stateElement.initializePromise);
7077
8011
  }
@@ -7102,7 +8036,7 @@ async function buildBindings(root) {
7102
8036
  }
7103
8037
  }
7104
8038
 
7105
- var version = "1.25.0";
8039
+ var version = "1.27.0";
7106
8040
  var pkg = {
7107
8041
  version: version};
7108
8042
 
@@ -7877,28 +8811,47 @@ function setStateElementByName(rootNode, name, element) {
7877
8811
  // 初めてルートノードに登録する場合
7878
8812
  // enable-ssr 属性があり、サーバーサイドでない場合はハイドレーション
7879
8813
  const enableSsr = !inSsr() && element.hasAttribute?.('enable-ssr');
8814
+ // instanceof ではなく constructor.name で判定するのは意図的。SSR では
8815
+ // @wcstack/server の installGlobals が happy-dom の一部だけを globalThis に載せるが、
8816
+ // そのリスト(GLOBALS_KEYS)に `Document` は入っていない。Node にも `Document` は
8817
+ // 無いので `rootNode instanceof Document` は ReferenceError になる。
8818
+ // `ShadowRoot` はリストに含まれるため他所では instanceof を使っている
8819
+ // (docs/architecture-hardening/15-state-component-mechanism-consistency.md §3.3)。
8820
+ // reject を配管しないと、バインディング初期化中の例外は unhandled rejection として
8821
+ // 漏れるだけで ready が永久に未解決のまま残り、await getBindingsReady() の先が
8822
+ // 無言でハングする(docs/state-bind-component-nested-for-design.md §8.2)。
7880
8823
  if (rootNode.constructor.name === 'HTMLDocument' || rootNode.constructor.name === 'Document') {
7881
- const ready = new Promise((resolve) => {
8824
+ const ready = new Promise((resolve, reject) => {
7882
8825
  queueMicrotask(async () => {
7883
- if (enableSsr) {
7884
- const success = await hydrateBindings(rootNode);
7885
- if (!success) {
8826
+ try {
8827
+ if (enableSsr) {
8828
+ const success = await hydrateBindings(rootNode);
8829
+ if (!success) {
8830
+ await buildBindings(rootNode);
8831
+ }
8832
+ }
8833
+ else {
7886
8834
  await buildBindings(rootNode);
7887
8835
  }
8836
+ resolve();
7888
8837
  }
7889
- else {
7890
- await buildBindings(rootNode);
8838
+ catch (error) {
8839
+ reject(error);
7891
8840
  }
7892
- resolve();
7893
8841
  });
7894
8842
  });
7895
8843
  bindingsReadyByNode.set(rootNode, ready);
7896
8844
  }
7897
8845
  else if (rootNode.constructor.name === 'ShadowRoot') {
7898
- const ready = new Promise((resolve) => {
8846
+ const ready = new Promise((resolve, reject) => {
7899
8847
  queueMicrotask(async () => {
7900
- await buildBindings(rootNode);
7901
- resolve();
8848
+ try {
8849
+ await buildBindings(rootNode);
8850
+ resolve();
8851
+ }
8852
+ catch (error) {
8853
+ reject(error);
8854
+ }
7902
8855
  });
7903
8856
  });
7904
8857
  bindingsReadyByNode.set(rootNode, ready);
@@ -7918,27 +8871,89 @@ function setStateElementByName(rootNode, name, element) {
7918
8871
  }
7919
8872
  }
7920
8873
 
7921
- 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 = [];
7922
8920
  /**
7923
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` を使うこと。
7924
8927
  */
7925
- function registerUpdateBatchListener(listener) {
7926
- 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
+ }
7927
8938
  }
7928
8939
  /**
7929
8940
  * drain 終了リスナーを解除する(テスト間の分離用)。
7930
8941
  */
7931
8942
  function unregisterUpdateBatchListener(listener) {
7932
- updateBatchListeners.delete(listener);
8943
+ const index = updateBatchListeners.findIndex((registered) => registered.listener === listener);
8944
+ if (index !== -1) {
8945
+ updateBatchListeners.splice(index, 1);
8946
+ }
7933
8947
  }
7934
8948
  /**
7935
- * 全リスナーに drain のバッチを通知する。
8949
+ * 全リスナーに drain のバッチを優先度順で通知する。
7936
8950
  * リスナーの throw は握りつぶさない(内部バグの隠蔽防止)。
7937
- * stream 側リスナーが entry ごとに自前で try/catch する契約(設計書 §3-2)。
8951
+ * stream / watch 側リスナーが entry ごとに自前で try/catch する契約(設計書 §3-2)。
7938
8952
  */
7939
8953
  function notifyUpdateBatchListeners(batch) {
7940
- for (const listener of updateBatchListeners) {
7941
- listener(batch);
8954
+ // 反復中の register / unregister(ハンドラ内の切断・再 set)に耐えるためコピーする
8955
+ for (const registered of updateBatchListeners.slice()) {
8956
+ registered.listener(batch);
7942
8957
  }
7943
8958
  }
7944
8959
  class Updater {
@@ -7946,6 +8961,9 @@ class Updater {
7946
8961
  constructor() {
7947
8962
  }
7948
8963
  enqueueAbsoluteAddress(absoluteAddress, context = null) {
8964
+ // `$watch` ハンドラ実行中の書き込みだけを連鎖としてマークする(watch/chainDepth.ts)。
8965
+ // ハンドラ実行中でなければ即 return する葉モジュール呼び出し 1 個のコスト。
8966
+ noteEnqueueForWatchChain();
7949
8967
  const requireStartProcess = this._queueUpdateRecords.length === 0;
7950
8968
  this._queueUpdateRecords.push({ absoluteAddress, context });
7951
8969
  if (requireStartProcess) {
@@ -8172,7 +9190,9 @@ function setSink(sink) {
8172
9190
  setDevtoolsSink(sink);
8173
9191
  const isActive = sink !== null;
8174
9192
  if (isActive && !wasActive) {
8175
- registerUpdateBatchListener(onUpdateBatch);
9193
+ // `$watch` / `$streams` restart より先に流す(protocol §4.3)。優先度を省略しても
9194
+ // 既定 0 で結果は同じだが、それは偶然なので定数で意図を固定する。
9195
+ registerUpdateBatchListener(onUpdateBatch, DEVTOOLS_LISTENER_PRIORITY);
8176
9196
  }
8177
9197
  else if (!isActive && wasActive) {
8178
9198
  unregisterUpdateBatchListener(onUpdateBatch);
@@ -8280,26 +9300,67 @@ function registerDevtoolsSource() {
8280
9300
  getOrCreateHookRegistry().register(source);
8281
9301
  }
8282
9302
 
9303
+ const CSP_GUIDE = "https://github.com/wcstack/wcstack/blob/main/docs/csp.md";
9304
+ /**
9305
+ * インライン `<script>` の評価失敗を、原因の分かるメッセージに変換する。
9306
+ *
9307
+ * CSP にブロックされた動的 import の rejection は
9308
+ * "Failed to fetch dynamically imported module" としか言わず、CSP には一切言及しない。
9309
+ * ブロックされた事実は securitypolicyviolation イベントでしか観測できないため、
9310
+ * その観測結果を `cspBlocked` で受け取る。
9311
+ *
9312
+ * 真ならブロック確定として対処方法まで書く。偽のときは構文エラー等と区別できないので、
9313
+ * 元のエラーを主にして CSP は参照先を添えるに留める(誤誘導を避ける)。
9314
+ */
9315
+ function describeImportFailure(name, error, cspBlocked) {
9316
+ const detail = error?.message ?? String(error);
9317
+ if (cspBlocked) {
9318
+ return `The inline <script> of state "${name}" was blocked by Content-Security-Policy. ` +
9319
+ `Inline state is evaluated through a blob: URL, so script-src must allow blob:. ` +
9320
+ `Prefer moving the state into an external file and loading it with src="./state.js", ` +
9321
+ `which requires no extra CSP directive. See ${CSP_GUIDE}`;
9322
+ }
9323
+ return `Failed to evaluate the inline <script> of state "${name}": ${detail}. ` +
9324
+ `If this page sets a Content-Security-Policy, see ${CSP_GUIDE}`;
9325
+ }
8283
9326
  async function loadFromInnerScript(script, name) {
8284
9327
  let scriptModule = null;
8285
9328
  const uniq_comment = `\n//# sourceURL=${name}\n`;
8286
- if (typeof URL.createObjectURL === 'function') {
8287
- // Create a blob URL for the script and dynamically import it
8288
- const blob = new Blob([script.text + uniq_comment], { type: "application/javascript" });
8289
- const url = URL.createObjectURL(blob);
8290
- try {
8291
- scriptModule = await import(url);
9329
+ // import() が失敗した理由が CSP かどうかを判別するために、評価の間だけ違反を購読する。
9330
+ let cspBlocked = false;
9331
+ const onViolation = (event) => {
9332
+ if (event.effectiveDirective.startsWith("script-src")) {
9333
+ cspBlocked = true;
8292
9334
  }
8293
- finally {
8294
- // Clean up blob URL to prevent memory leak
8295
- URL.revokeObjectURL(url);
9335
+ };
9336
+ document.addEventListener("securitypolicyviolation", onViolation);
9337
+ try {
9338
+ if (typeof URL.createObjectURL === 'function') {
9339
+ // Create a blob URL for the script and dynamically import it
9340
+ const blob = new Blob([script.text + uniq_comment], { type: "application/javascript" });
9341
+ const url = URL.createObjectURL(blob);
9342
+ try {
9343
+ scriptModule = await import(url);
9344
+ }
9345
+ finally {
9346
+ // Clean up blob URL to prevent memory leak
9347
+ URL.revokeObjectURL(url);
9348
+ }
9349
+ }
9350
+ else {
9351
+ // Fallback: Base64 encoding method (for test environment)
9352
+ // Convert script to Base64 and import via data: URL
9353
+ const b64 = btoa(String.fromCodePoint(...new TextEncoder().encode(script.text + uniq_comment)));
9354
+ scriptModule = await import(`data:application/javascript;base64,${b64}`);
8296
9355
  }
8297
9356
  }
8298
- else {
8299
- // Fallback: Base64 encoding method (for test environment)
8300
- // Convert script to Base64 and import via data: URL
8301
- const b64 = btoa(String.fromCodePoint(...new TextEncoder().encode(script.text + uniq_comment)));
8302
- scriptModule = await import(`data:application/javascript;base64,${b64}`);
9357
+ catch (e) {
9358
+ // 呼び出し元(State._initialize / _initializeDCC)が raiseError
9359
+ // `[@wcstack/state]` を付けるため、ここでは prefix を重ねない。
9360
+ throw new Error(describeImportFailure(name, e, cspBlocked), { cause: e });
9361
+ }
9362
+ finally {
9363
+ document.removeEventListener("securitypolicyviolation", onViolation);
8303
9364
  }
8304
9365
  return (scriptModule && typeof scriptModule.default === 'object') ? scriptModule.default : {};
8305
9366
  }
@@ -8346,6 +9407,10 @@ function loadFromScriptJson(id) {
8346
9407
  class LoopContextStack {
8347
9408
  _loopContextStack = Array(MAX_LOOP_DEPTH).fill(undefined);
8348
9409
  _length = 0;
9410
+ _getBaseDepth;
9411
+ constructor(getBaseDepth) {
9412
+ this._getBaseDepth = getBaseDepth;
9413
+ }
8349
9414
  createLoopContext(elementStateAddress, callback) {
8350
9415
  if (elementStateAddress.listIndex === null) {
8351
9416
  raiseError(`Cannot create loop context for a state address that does not have a list index.`);
@@ -8367,13 +9432,21 @@ class LoopContextStack {
8367
9432
  }
8368
9433
  else {
8369
9434
  // With no active loop context the address must be self-contained: the
8370
- // listIndex chain supplies one index per wildcard. Top-level lists
8371
- // (wildcardCount 1) always satisfy this. A nested list re-rendered
8372
- // directly (e.g. replaced via $resolve from outside the loop) also
8373
- // satisfies it — the for binding's listIndex carries the full ancestor
8374
- // chain.
9435
+ // listIndex chain supplies one index per wildcard, plus this scope's base
9436
+ // depth Δ. Top-level lists (wildcardCount 1) always satisfy this. A nested
9437
+ // list re-rendered directly (e.g. replaced via $resolve from outside the
9438
+ // loop) also satisfies it — the for binding's listIndex carries the full
9439
+ // ancestor chain. Δ is non-zero only for a mapped bind-component child
9440
+ // whose host sits inside a parent-scope `for`
9441
+ // (docs/state-bind-component-nested-for-design.md).
9442
+ // ここは行ごとに通る(applyChangeToFor は追加行ごとに createLoopContext する)。
9443
+ // Δ=0 の判定を先に置き、通れば base 深さの解決(DOM の親走査を含む)に
9444
+ // 一切触れない — 通常の state に追加コストを載せないため。
8375
9445
  if (loopContext.listIndex.length !== loopContext.pathInfo.wildcardCount) {
8376
- raiseError(`Cannot push loop context when there is no active loop context: the list index chain (length ${loopContext.listIndex.length}) does not cover the wildcard path (wildcard count ${loopContext.pathInfo.wildcardCount}).`);
9446
+ const baseDepth = this._getBaseDepth();
9447
+ if (loopContext.listIndex.length !== loopContext.pathInfo.wildcardCount + baseDepth) {
9448
+ raiseError(`Cannot push loop context when there is no active loop context: the list index chain (length ${loopContext.listIndex.length}) does not cover the wildcard path (wildcard count ${loopContext.pathInfo.wildcardCount}, base depth ${baseDepth}).`);
9449
+ }
8377
9450
  }
8378
9451
  }
8379
9452
  this._loopContextStack[this._length] = loopContext;
@@ -8397,8 +9470,8 @@ class LoopContextStack {
8397
9470
  return retValue;
8398
9471
  }
8399
9472
  }
8400
- function createLoopContextStack() {
8401
- return new LoopContextStack();
9473
+ function createLoopContextStack(getBaseDepth = () => 0) {
9474
+ return new LoopContextStack(getBaseDepth);
8402
9475
  }
8403
9476
 
8404
9477
  /**
@@ -8436,12 +9509,12 @@ function processCommandTokensDeclaration(state) {
8436
9509
  return names;
8437
9510
  }
8438
9511
 
8439
- const registryByStateElement$1 = new WeakMap();
9512
+ const registryByStateElement$2 = new WeakMap();
8440
9513
  function getOrCreateCommandToken(stateElement, name) {
8441
- let registry = registryByStateElement$1.get(stateElement);
9514
+ let registry = registryByStateElement$2.get(stateElement);
8442
9515
  if (typeof registry === "undefined") {
8443
9516
  registry = new Map();
8444
- registryByStateElement$1.set(stateElement, registry);
9517
+ registryByStateElement$2.set(stateElement, registry);
8445
9518
  }
8446
9519
  let token = registry.get(name);
8447
9520
  if (typeof token === "undefined") {
@@ -8451,7 +9524,7 @@ function getOrCreateCommandToken(stateElement, name) {
8451
9524
  return token;
8452
9525
  }
8453
9526
  function clearCommandTokenRegistry(stateElement) {
8454
- registryByStateElement$1.delete(stateElement);
9527
+ registryByStateElement$2.delete(stateElement);
8455
9528
  }
8456
9529
 
8457
9530
  /**
@@ -8686,24 +9759,24 @@ function invalidateLastNotified(stateElement, name) {
8686
9759
  * 設計書 §3-2 の「未接続(disconnect 済み)の stateElement の entry は restart
8687
9760
  * しない」はこの不変条件で担保される。
8688
9761
  */
8689
- const activeStateElements = new Set();
9762
+ const activeStateElements$1 = new Set();
8690
9763
  /**
8691
9764
  * 起動中 stateElement として登録する(startStreams 専用。不変条件はモジュールヘッダ参照)。
8692
9765
  */
8693
9766
  function addActiveStateElement(stateElement) {
8694
- activeStateElements.add(stateElement);
9767
+ activeStateElements$1.add(stateElement);
8695
9768
  }
8696
9769
  /**
8697
9770
  * 起動中 stateElement から外す(abortAllStreams / clearStreamRegistry 専用)。
8698
9771
  */
8699
9772
  function deleteActiveStateElement(stateElement) {
8700
- activeStateElements.delete(stateElement);
9773
+ activeStateElements$1.delete(stateElement);
8701
9774
  }
8702
9775
  /**
8703
9776
  * 起動中 stateElement を列挙する(drain リスナーの交差判定用)。
8704
9777
  */
8705
9778
  function getActiveStateElements() {
8706
- return activeStateElements;
9779
+ return activeStateElements$1;
8707
9780
  }
8708
9781
 
8709
9782
  /**
@@ -8716,18 +9789,18 @@ function getActiveStateElements() {
8716
9789
  * - disconnect 時は abortAllStreams(abort のみ・registry 保持)、
8717
9790
  * `_state` 再 set 時のみ clearStreamRegistry(abort + 全削除)。
8718
9791
  */
8719
- const registryByStateElement = new WeakMap();
9792
+ const registryByStateElement$1 = new WeakMap();
8720
9793
  /**
8721
9794
  * stream entry 群を置換登録する(`_state` セッターからの再構築で丸ごと差し替える)。
8722
9795
  */
8723
9796
  function setStreamEntries(stateElement, entries) {
8724
- registryByStateElement.set(stateElement, entries);
9797
+ registryByStateElement$1.set(stateElement, entries);
8725
9798
  }
8726
9799
  /**
8727
9800
  * 登録済みの stream entry 群を返す。未登録なら空 Map を返す(registry への登録はしない)。
8728
9801
  */
8729
9802
  function getStreamEntries(stateElement) {
8730
- return registryByStateElement.get(stateElement) ?? new Map();
9803
+ return registryByStateElement$1.get(stateElement) ?? new Map();
8731
9804
  }
8732
9805
  /**
8733
9806
  * 全 stream を abort して idle に戻す(設計書 §5-1)。registry は保持する。
@@ -8747,7 +9820,7 @@ function abortAllStreams(stateElement) {
8747
9820
  // 設計書 §3-2。add 側は startStreams — stream/activeStateElements.ts の
8748
9821
  // リーク防止不変条件を参照)。registry の有無に関わらず必ず外す。
8749
9822
  deleteActiveStateElement(stateElement);
8750
- const entries = registryByStateElement.get(stateElement);
9823
+ const entries = registryByStateElement$1.get(stateElement);
8751
9824
  if (typeof entries === "undefined") {
8752
9825
  return;
8753
9826
  }
@@ -8767,7 +9840,7 @@ function clearStreamRegistry(stateElement) {
8767
9840
  // abortAllStreams が既に delete 済みだが、「clear = 全削除でも必ず restart 対象から
8768
9841
  // 外れる」不変条件を将来の abortAllStreams の変更から独立に保証するため明示的に呼ぶ。
8769
9842
  deleteActiveStateElement(stateElement);
8770
- registryByStateElement.delete(stateElement);
9843
+ registryByStateElement$1.delete(stateElement);
8771
9844
  }
8772
9845
 
8773
9846
  /**
@@ -8880,6 +9953,77 @@ function processStreamsDeclaration(stateElement, state) {
8880
9953
  pruneLastNotified(stateElement, new Set(entries.keys()));
8881
9954
  }
8882
9955
 
9956
+ /**
9957
+ * list/listKeys.ts
9958
+ *
9959
+ * `$listKeys: { <listPath>: <fieldName | (row) => key> }` 宣言マップを解析し、
9960
+ * 「リストパス → キー指定」表を構築する(docs/state-list-key-design.md §3)。
9961
+ *
9962
+ * この表が存在するリストパスへの配列代入は、setByAddress でキー突合され、
9963
+ * 一致行は旧オブジェクトを据え置いたまま変化フィールドだけが per-path 書き込みで
9964
+ * 流し込まれる(§2)。未宣言なら書き込み経路は従来と完全に同一。
9965
+ *
9966
+ * 「そのパスが実際にリストか」は宣言時には判定できない(listPaths は
9967
+ * バインディング収集時に確定する)。実行時に配列でなければ経路に入らないだけで、
9968
+ * 宣言自体はエラーにしない。
9969
+ */
9970
+ /**
9971
+ * `$listKeys` 宣言を検証して Map 化する。宣言が無ければ null(=ゼロコスト経路)。
9972
+ *
9973
+ * 検証内容(§3.1):
9974
+ * - `$listKeys` はオブジェクト
9975
+ * - パスは非空文字列 / 空セグメント・先頭末尾の `.` を禁止
9976
+ * - パス末尾が `*` であることを禁止(リストパスであって要素パスではない)
9977
+ * - キー指定は非空文字列か関数
9978
+ * - 文字列キーは `.` / `*` を含まないフラットなフィールド名
9979
+ * - `Object.prototype` 継承名を禁止(`__proto__` / `constructor` 等)
9980
+ */
9981
+ function processListKeysDeclaration(state) {
9982
+ const declared = state[STATE_LIST_KEYS_NAME];
9983
+ if (typeof declared === "undefined") {
9984
+ return null;
9985
+ }
9986
+ if (typeof declared !== "object" || declared === null) {
9987
+ raiseError(`${STATE_LIST_KEYS_NAME} must be an object mapping list paths to key specs.`);
9988
+ }
9989
+ const entries = new Map();
9990
+ for (const [path, spec] of Object.entries(declared)) {
9991
+ if (path.length === 0) {
9992
+ raiseError(`${STATE_LIST_KEYS_NAME} entry path must be a non-empty string.`);
9993
+ }
9994
+ const segments = path.split(DELIMITER);
9995
+ if (segments.some((segment) => segment.length === 0)) {
9996
+ raiseError(`${STATE_LIST_KEYS_NAME} entry "${path}" must not contain empty path segments.`);
9997
+ }
9998
+ if (segments[segments.length - 1] === WILDCARD) {
9999
+ raiseError(`${STATE_LIST_KEYS_NAME} entry "${path}" must be the list path itself, not the element path ` +
10000
+ `(drop the trailing "${DELIMITER}${WILDCARD}").`);
10001
+ }
10002
+ if (typeof spec === "function") {
10003
+ entries.set(path, spec);
10004
+ continue;
10005
+ }
10006
+ if (typeof spec !== "string") {
10007
+ raiseError(`${STATE_LIST_KEYS_NAME} entry "${path}" key spec must be a field name (string) or a function.`);
10008
+ }
10009
+ if (spec.length === 0) {
10010
+ raiseError(`${STATE_LIST_KEYS_NAME} entry "${path}" key field name must be a non-empty string.`);
10011
+ }
10012
+ if (spec.includes(DELIMITER) || spec.includes(WILDCARD)) {
10013
+ raiseError(`${STATE_LIST_KEYS_NAME} entry "${path}" key field name "${spec}" must be a flat property name ` +
10014
+ `("${DELIMITER}" / "${WILDCARD}" are not allowed).`);
10015
+ }
10016
+ // own key でなくても `in` 判定が真になる継承名は、キー抽出が prototype 由来の
10017
+ // 値(constructor など)を拾って全行同一キー扱いになるため名前の防衛線で落とす。
10018
+ if (spec in Object.prototype) {
10019
+ raiseError(`${STATE_LIST_KEYS_NAME} entry "${path}" key field name "${spec}" must not be a property name ` +
10020
+ `inherited from Object.prototype (e.g. "__proto__", "constructor").`);
10021
+ }
10022
+ entries.set(path, spec);
10023
+ }
10024
+ return entries.size > 0 ? entries : null;
10025
+ }
10026
+
8883
10027
  /**
8884
10028
  * stream/streamNamespace.ts
8885
10029
  *
@@ -9422,117 +10566,746 @@ function restartStreamsOnUpdateBatch(batch) {
9422
10566
  }
9423
10567
  }
9424
10568
  }
9425
- registerUpdateBatchListener(restartStreamsOnUpdateBatch);
10569
+ // 優先度で `$watch` の後に固定する(設計書 §3-2 層 1)。import 順には依存しない。
10570
+ registerUpdateBatchListener(restartStreamsOnUpdateBatch, STREAM_LISTENER_PRIORITY);
9426
10571
 
9427
- function getterFn(name) {
9428
- return function () {
9429
- const stateEl = this.stateElement;
9430
- if (!stateEl)
9431
- return undefined;
9432
- let value;
9433
- try {
9434
- stateEl.createState("readonly", (state) => {
9435
- value = state[name];
9436
- });
9437
- }
9438
- catch (e) {
9439
- console.warn(`[@wcstack/state] DCC getter "${name}" failed:`, e);
9440
- return undefined;
9441
- }
9442
- return value;
9443
- };
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);
9444
10607
  }
9445
- function setterFn(name) {
9446
- return function (value) {
9447
- const stateEl = this.stateElement;
9448
- if (!stateEl)
9449
- return;
9450
- stateEl.initializePromise.then(() => {
9451
- stateEl.createState("writable", (state) => {
9452
- state[name] = value;
9453
- });
9454
- });
9455
- };
10608
+ /**
10609
+ * 登録済みの watch entry 群を返す。未登録なら共有の空 Map を返す
10610
+ * (registry への登録はしない。返り値は読み出し専用)。
10611
+ */
10612
+ function getWatchEntries(stateElement) {
10613
+ return registryByStateElement.get(stateElement) ?? EMPTY_ENTRIES;
9456
10614
  }
9457
- function callFn(name, isAsync) {
9458
- if (isAsync) {
9459
- return function (...args) {
9460
- const stateEl = this.stateElement;
9461
- if (!stateEl)
9462
- return undefined;
9463
- return stateEl.initializePromise.then(() => {
9464
- let result;
9465
- return stateEl.createStateAsync("writable", async (state) => {
9466
- result = await state[name](...args);
9467
- }).then(() => result);
9468
- });
9469
- };
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;
9470
10675
  }
9471
- return function (...args) {
9472
- const stateEl = this.stateElement;
9473
- if (!stateEl)
9474
- return undefined;
9475
- return stateEl.initializePromise.then(() => {
9476
- let result;
9477
- stateEl.createState("writable", (state) => {
9478
- result = state[name](...args);
9479
- });
9480
- return result;
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++,
9481
10718
  });
9482
- };
9483
- }
9484
- function isInternalProperty(name) {
9485
- return name.startsWith("$");
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;
9486
10726
  }
9487
10727
 
9488
- function createWcBindable(tagName, bindables) {
9489
- const properties = bindables.map((propName) => ({
9490
- name: propName,
9491
- event: `${tagName}:${propName}-changed`,
9492
- }));
9493
- // Every $bindables member gets both a getter and a setter on the DCC prototype,
9494
- // so declare it in inputs as well — a property declared only in `properties` is
9495
- // output-only under directional initial sync, which would permanently block
9496
- // parent-state DCC writes.
9497
- const inputs = bindables.map((propName) => ({
9498
- name: propName,
9499
- }));
9500
- return {
9501
- protocol: "wc-bindable",
9502
- version: 1,
9503
- properties,
9504
- inputs,
9505
- };
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);
9506
10745
  }
9507
- function createBindableEventMap(tagName, bindables) {
9508
- const map = {};
9509
- for (const propName of bindables) {
9510
- map[propName] = `${tagName}:${propName}-changed`;
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);
9511
10751
  }
9512
- return map;
10752
+ snapshots.set(absAddress, value);
10753
+ }
10754
+ /** `_state` 再 set で宣言ごと作り直すときに破棄する */
10755
+ function clearComputedSnapshots(stateElement) {
10756
+ snapshotsByStateElement.delete(stateElement);
9513
10757
  }
9514
10758
 
9515
- function defineDCC(hostElement, shadowRoot, state) {
9516
- const tagName = hostElement.tagName.toLowerCase();
9517
- // バリデーション
9518
- if (!tagName.includes("-")) {
9519
- raiseError(`DCC: "${tagName}" is not a valid custom element name (must contain a hyphen).`);
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;
9520
10785
  }
9521
- if (customElements.get(tagName)) {
9522
- // 既に登録済みならスキップ(重複定義の検知のため警告は出す)
9523
- console.warn(`[@wcstack/state] DCC: "${tagName}" is already registered. Skipping redefinition.`);
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) {
9524
10838
  return;
9525
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);
11064
+
11065
+ function getterFn(name) {
11066
+ return function () {
11067
+ const stateEl = this.stateElement;
11068
+ if (!stateEl)
11069
+ return undefined;
11070
+ // state のロード前は「まだ値が無い」だけで異常ではない。行がまだ fragment 上にある間の
11071
+ // 初期スナップショット読み(BindingSession.readProducerSnapshot)は必ずここを通るので、
11072
+ // warn を出すと通常フローが騒がしくなる
11073
+ // (docs/architecture-hardening/15-state-component-mechanism-consistency.md §2.2)。
11074
+ if (stateEl.initialized !== true)
11075
+ return undefined;
11076
+ let value;
11077
+ try {
11078
+ stateEl.createState("readonly", (state) => {
11079
+ value = state[name];
11080
+ });
11081
+ }
11082
+ catch (e) {
11083
+ console.warn(`[@wcstack/state] DCC getter "${name}" failed:`, e);
11084
+ return undefined;
11085
+ }
11086
+ return value;
11087
+ };
11088
+ }
11089
+ function setterFn(name) {
11090
+ return function (value) {
11091
+ const stateEl = this.stateElement;
11092
+ if (!stateEl)
11093
+ return;
11094
+ // 初期化済みなら同期で書く。getter は同期なので、ここを常に initializePromise 経由に
11095
+ // すると `el.count = 5; el.count` が旧値を返す(§2.2)。未初期化のときだけ遅延させる
11096
+ // = 未接続の行に書かれた値が捨てられないための経路(§1.4)はそのまま残る。
11097
+ if (stateEl.initialized === true) {
11098
+ stateEl.createState("writable", (state) => {
11099
+ state[name] = value;
11100
+ });
11101
+ return;
11102
+ }
11103
+ stateEl.initializePromise.then(() => {
11104
+ stateEl.createState("writable", (state) => {
11105
+ state[name] = value;
11106
+ });
11107
+ });
11108
+ };
11109
+ }
11110
+ function callFn(name, isAsync) {
11111
+ // 戻り値は常に Promise。state 側のメソッドが同期でも初期化待ちが挟まりうるため、
11112
+ // 呼び出し側から見た型を揃える(wcBindable.commands が一律 `async: true` を宣言するのと対)。
11113
+ if (isAsync) {
11114
+ return function (...args) {
11115
+ const stateEl = this.stateElement;
11116
+ if (!stateEl)
11117
+ return undefined;
11118
+ return stateEl.initializePromise.then(() => {
11119
+ let result;
11120
+ return stateEl.createStateAsync("writable", async (state) => {
11121
+ result = await state[name](...args);
11122
+ }).then(() => result);
11123
+ });
11124
+ };
11125
+ }
11126
+ return function (...args) {
11127
+ const stateEl = this.stateElement;
11128
+ if (!stateEl)
11129
+ return undefined;
11130
+ return stateEl.initializePromise.then(() => {
11131
+ let result;
11132
+ stateEl.createState("writable", (state) => {
11133
+ result = state[name](...args);
11134
+ });
11135
+ return result;
11136
+ });
11137
+ };
11138
+ }
11139
+ function isInternalProperty(name) {
11140
+ return name.startsWith("$");
11141
+ }
11142
+
11143
+ function getAllPropertyDescriptors(obj) {
11144
+ const chain = [];
11145
+ let proto = obj;
11146
+ while (proto && proto !== Object.prototype) {
11147
+ chain.push(proto);
11148
+ proto = Object.getPrototypeOf(proto);
11149
+ }
11150
+ const descriptors = {};
11151
+ for (let i = chain.length - 1; i >= 0; i--) {
11152
+ Object.assign(descriptors, Object.getOwnPropertyDescriptors(chain[i]));
11153
+ }
11154
+ return descriptors;
11155
+ }
11156
+
11157
+ /**
11158
+ * DCC の `$bindables` / `$commands` 宣言を解析・検証する。
11159
+ *
11160
+ * 検証の強度は `$commandTokens` / `$eventTokens` と揃える
11161
+ * (docs/architecture-hardening/15-state-component-mechanism-consistency.md §1.5 / §2.3 / §1.6)。
11162
+ * 従来 `$bindables` は `Array.isArray(...) ? ... : []` だけで、
11163
+ *
11164
+ * - 非配列を無言で空扱いにする
11165
+ * - 重複名をそのまま `createWcBindable` に流す
11166
+ * - `$` 始まりの名前を通す
11167
+ * - state に存在しない名前を通す
11168
+ *
11169
+ * という 4 つの穴があった。特に重複は害が大きい: `readNamedList`(protocol/wcBindableReader.ts)は
11170
+ * 重複名を見つけると `null` を返すため、`readBindableDeclaration()` が宣言全体を棄却し、
11171
+ * 双方向バインド・spread・initialSync の bindable 判定が**警告なしで**丸ごと死ぬ。
11172
+ * 自前のファクトリが自前の reader に棄却される状態なので、生成前に落とす。
11173
+ */
11174
+ function readNameList(state, declarationName) {
11175
+ const declared = state[declarationName];
11176
+ if (typeof declared === "undefined") {
11177
+ return null;
11178
+ }
11179
+ if (!Array.isArray(declared)) {
11180
+ raiseError(`${declarationName} must be an array of strings.`);
11181
+ }
11182
+ const names = [];
11183
+ const seen = new Set();
11184
+ for (const name of declared) {
11185
+ if (typeof name !== "string" || name.length === 0) {
11186
+ raiseError(`${declarationName} entries must be non-empty strings.`);
11187
+ }
11188
+ if (name.startsWith("$")) {
11189
+ raiseError(`${declarationName} entry "${name}" must not start with "$" (internal properties are not exposed on the component).`);
11190
+ }
11191
+ if (seen.has(name)) {
11192
+ raiseError(`${declarationName} entry "${name}" is duplicated.`);
11193
+ }
11194
+ seen.add(name);
11195
+ names.push(name);
11196
+ }
11197
+ return names;
11198
+ }
11199
+ /**
11200
+ * `$streams` が宣言している名前。値プロパティはインスタンス側の実体化まで state 上に
11201
+ * 現れないため、存在検査ではここも「実在する名前」として扱う(§2.3)。
11202
+ * 宣言そのものの妥当性検査は processStreamsDeclaration の責務なので、ここでは
11203
+ * キーの取り出しだけを行い、形が違えば黙って空集合を返す。
11204
+ */
11205
+ function getStreamNames(state) {
11206
+ const declared = state[STATE_STREAMS_NAME];
11207
+ if (typeof declared !== "object" || declared === null) {
11208
+ return new Set();
11209
+ }
11210
+ return new Set(Object.keys(declared));
11211
+ }
11212
+ function processDccDeclarations(state) {
11213
+ const bindables = readNameList(state, STATE_BINDABLES_NAME) ?? [];
11214
+ const commands = readNameList(state, STATE_COMMANDS_NAME) ?? [];
11215
+ const descriptors = getAllPropertyDescriptors(state);
11216
+ const streamNames = getStreamNames(state);
11217
+ const streamBackedBindables = [];
11218
+ for (const name of bindables) {
11219
+ const descriptor = descriptors[name];
11220
+ if (typeof descriptor === "undefined") {
11221
+ // `$streams` 由来なら実体化後に現れるので通す。アクセサはこちらで補う。
11222
+ if (streamNames.has(name)) {
11223
+ streamBackedBindables.push(name);
11224
+ continue;
11225
+ }
11226
+ raiseError(`${STATE_BINDABLES_NAME} entry "${name}" is not declared on the state.`);
11227
+ }
11228
+ if (typeof descriptor.value === "function") {
11229
+ raiseError(`${STATE_BINDABLES_NAME} entry "${name}" is a method. Declare it in ${STATE_COMMANDS_NAME} instead.`);
11230
+ }
11231
+ }
11232
+ for (const name of commands) {
11233
+ const descriptor = descriptors[name];
11234
+ if (typeof descriptor === "undefined") {
11235
+ raiseError(`${STATE_COMMANDS_NAME} entry "${name}" is not declared on the state.`);
11236
+ }
11237
+ if (typeof descriptor.value !== "function") {
11238
+ raiseError(`${STATE_COMMANDS_NAME} entry "${name}" is not a method. Declare it in ${STATE_BINDABLES_NAME} instead.`);
11239
+ }
11240
+ }
11241
+ return { bindables, commands, streamBackedBindables };
11242
+ }
11243
+
11244
+ function createWcBindable(tagName, bindables, commands = []) {
11245
+ const properties = bindables.map((propName) => ({
11246
+ name: propName,
11247
+ event: `${tagName}:${propName}-changed`,
11248
+ // Read the member off the element instead of trusting event.detail. The event is a
11249
+ // notification, not a carrier: a sub-path write (`user.name = "x"` against a `user`
11250
+ // member) has no single value to put in detail, and a state-side setter may normalize
11251
+ // what was written. Both cases are correct through the property.
11252
+ getter: (event) => event.target[propName],
11253
+ }));
11254
+ // Every $bindables member gets both a getter and a setter on the DCC prototype,
11255
+ // so declare it in inputs as well — a property declared only in `properties` is
11256
+ // output-only under directional initial sync, which would permanently block
11257
+ // parent-state → DCC writes.
11258
+ const inputs = bindables.map((propName) => ({
11259
+ name: propName,
11260
+ }));
11261
+ const declaration = {
11262
+ protocol: "wc-bindable",
11263
+ version: 1,
11264
+ properties,
11265
+ inputs,
11266
+ };
11267
+ if (commands.length === 0) {
11268
+ return declaration;
11269
+ }
11270
+ // `async: true` is uniform on purpose: dccPropertyFactories.callFn always chains on the
11271
+ // inner <wcs-state>'s initializePromise, so a DCC command returns a Promise whether or not
11272
+ // the underlying state method was declared `async`. Reporting the state method's own
11273
+ // asyncness would describe something callers never observe.
11274
+ const declaredCommands = commands.map((name) => ({
11275
+ name,
11276
+ async: true,
11277
+ }));
11278
+ return { ...declaration, commands: declaredCommands };
11279
+ }
11280
+ function createBindableEventMap(tagName, bindables) {
11281
+ const map = {};
11282
+ for (const propName of bindables) {
11283
+ map[propName] = `${tagName}:${propName}-changed`;
11284
+ }
11285
+ return map;
11286
+ }
11287
+
11288
+ function defineDCC(hostElement, shadowRoot, state) {
11289
+ const tagName = hostElement.tagName.toLowerCase();
11290
+ // バリデーション
11291
+ if (!tagName.includes("-")) {
11292
+ raiseError(`DCC: "${tagName}" is not a valid custom element name (must contain a hyphen).`);
11293
+ }
11294
+ if (customElements.get(tagName)) {
11295
+ // 重複定義は authoring error として落とす。従来は warn してスキップしていたが、
11296
+ // 先勝ちで別テンプレートのインスタンスが生えるため「動いているように見えて中身が違う」
11297
+ // 状態になる。state 名の重複(stateElementByName)が raiseError なのと作法を揃える
11298
+ // (docs/architecture-hardening/15-state-component-mechanism-consistency.md §3.4)。
11299
+ raiseError(`DCC: "${tagName}" is already registered. A custom element name can only be defined once.`);
11300
+ }
9526
11301
  // ShadowRoot は cloneNode 不可のため、template 経由で内容をクローン
9527
11302
  const template = document.createElement("template");
9528
11303
  template.innerHTML = shadowRoot.innerHTML;
9529
11304
  const shadowRootMode = shadowRoot.mode;
9530
- // $bindables から wcBindable + bindableEventMap を生成
9531
- const bindables = Array.isArray(state[STATE_BINDABLES_NAME])
9532
- ? state[STATE_BINDABLES_NAME]
9533
- : [];
9534
- const wcBindable = bindables.length > 0
9535
- ? createWcBindable(tagName, bindables)
11305
+ // $bindables / $commands から wcBindable + bindableEventMap を生成
11306
+ const { bindables, commands, streamBackedBindables } = processDccDeclarations(state);
11307
+ const wcBindable = (bindables.length > 0 || commands.length > 0)
11308
+ ? createWcBindable(tagName, bindables, commands)
9536
11309
  : null;
9537
11310
  const bindableEventMap = bindables.length > 0
9538
11311
  ? createBindableEventMap(tagName, bindables)
@@ -9545,27 +11318,79 @@ function defineDCC(hostElement, shadowRoot, state) {
9545
11318
  static wcBindable = wcBindable;
9546
11319
  static bindableEventMap = bindableEventMap;
9547
11320
  _shadow = null;
9548
- connectedCallback() {
11321
+ /**
11322
+ * shadow を遅延構築する。定義要素(`data-wc-definition`)では null を返す。
11323
+ *
11324
+ * connectedCallback ではなくここで張るのは、**接続前にアクセサが呼ばれる**ため
11325
+ * (§1.4)。`for` の全追加パスは行を fragment に組み立ててからバインドを適用し、
11326
+ * fragment を DOM に挿すのは最後なので、`element.count = v` の時点で行はまだ未接続。
11327
+ * shadow が無いと `stateElement` が null になり、setterFn が無言で書き込みを捨てていた。
11328
+ * ここで構築しておけば、書き込みは inner `<wcs-state>` の initializePromise に
11329
+ * 積まれ、接続・state ロード後に適用される。
11330
+ *
11331
+ * 冪等なので再接続でも張り直さない。shadow tree は host の切断後も保持され、
11332
+ * 2 回目の attachShadow は NotSupportedError になる(§1.3)。`if` の false→true
11333
+ * 再マウントと `for` の行プーリングはどちらも同一ノードを unmount → mount する。
11334
+ * closed mode では `this.shadowRoot` が null なので判定はフィールド側で行う。
11335
+ *
11336
+ * G4 は「constructor へ前倒し」で決着したが、実装は constructor ではなく
11337
+ * この遅延構築を採った。目的(未接続でもアクセサが動く)は同じで、constructor 版だと
11338
+ * (1) 定義要素の判定に属性を読む必要があり constructor の作法に反する、
11339
+ * (2) 同一タグの `data-wc-definition` が 2 つある場合、DSD の shadow を既に持つ
11340
+ * 2 つ目に attachShadow して throw する、の 2 点を踏むため。
11341
+ */
11342
+ _ensureShadow() {
11343
+ if (this._shadow !== null)
11344
+ return this._shadow;
9549
11345
  if (this.hasAttribute(DCC_DEFINITION_ATTRIBUTE))
9550
- return;
11346
+ return null;
9551
11347
  this._shadow = this.attachShadow({ mode: DCCElement.shadowRootMode });
9552
11348
  this._shadow.appendChild(DCCElement.template.content.cloneNode(true));
9553
- // bindableEventMap の設定
11349
+ // template.content は inert なテンプレート所有ドキュメントに属するため、その clone は
11350
+ // カスタム要素として upgrade されていない。ホストが接続済みなら appendChild の時点で
11351
+ // upgrade されるが、未接続の shadow に挿した場合は upgrade 契機が無く、内側の
11352
+ // <wcs-state> が素の HTMLElement のまま残って createState が生えない。明示的に upgrade する。
11353
+ const registry = getCustomElementRegistry();
11354
+ if (registry !== null) {
11355
+ upgradeCustomElement(registry, this._shadow);
11356
+ }
11357
+ return this._shadow;
11358
+ }
11359
+ connectedCallback() {
11360
+ const shadow = this._ensureShadow();
11361
+ if (shadow === null)
11362
+ return;
11363
+ // bindableEventMap の設定。
11364
+ // initializePromise は待たない。待つと state のロード完了まで map が空のままで、
11365
+ // $connectedCallback 内で行った初期変更が変更イベントを出さない
11366
+ // (docs/architecture-hardening/15-state-component-mechanism-consistency.md §2.7)。
11367
+ // setBindableEventMap はフィールド代入だけで state を参照しないので、
11368
+ // <wcs-state> の初期化前に呼んでも安全。
9554
11369
  if (Object.keys(DCCElement.bindableEventMap).length > 0) {
9555
- const stateEl = this._shadow.querySelector(stateTagSelector);
11370
+ const stateEl = shadow.querySelector(stateTagSelector);
9556
11371
  if (stateEl) {
9557
- stateEl.initializePromise.then(() => {
9558
- stateEl.setBindableEventMap(DCCElement.bindableEventMap);
9559
- });
11372
+ stateEl.setBindableEventMap(DCCElement.bindableEventMap);
11373
+ }
11374
+ else {
11375
+ // $bindables を宣言しているのに束ねる先が無い。stateTagSelector は
11376
+ // `:not([name])` なので name 付きの <wcs-state> は一致せず、この分岐に落ちると
11377
+ // 変更イベントが一切出ないまま静かに壊れる
11378
+ // (docs/architecture-hardening/15-state-component-mechanism-consistency.md §2.5)。
11379
+ console.warn(`[@wcstack/state] DCC: "${tagName}" declares ${STATE_BINDABLES_NAME} but its template has no <${config.tagNames.state}> without a "name" attribute. Change events will not be dispatched.`);
9560
11380
  }
9561
11381
  }
9562
11382
  }
9563
11383
  get stateElement() {
9564
- return this._shadow?.querySelector(stateTagSelector);
11384
+ // 未接続でも shadow を構築して解決する(§1.4)。
11385
+ return (this._ensureShadow()?.querySelector(stateTagSelector) ?? null);
9565
11386
  }
9566
11387
  };
9567
- // state プロパティを走査して DCC クラスのプロトタイプにgetter/setter/methodを定義
9568
- const descriptors = Object.getOwnPropertyDescriptors(state);
11388
+ // state プロパティを走査して DCC クラスのプロトタイプにgetter/setter/methodを定義。
11389
+ // 走査範囲は State の getterPaths / setterPaths 収集と同じ「自身+プロトタイプチェーン」に
11390
+ // 揃える。own descriptor だけを見ていた頃は、クラスインスタンスや Object.create(proto) の
11391
+ // state で「getterPaths には載るのにアクセサが生えない」乖離が出ていた
11392
+ // (docs/architecture-hardening/15-state-component-mechanism-consistency.md §2.4)。
11393
+ const descriptors = getAllPropertyDescriptors(state);
9569
11394
  for (const [name, desc] of Object.entries(descriptors)) {
9570
11395
  if (isInternalProperty(name))
9571
11396
  continue;
@@ -9580,6 +11405,17 @@ function defineDCC(hostElement, shadowRoot, state) {
9580
11405
  }
9581
11406
  Object.defineProperty(DCCElement.prototype, name, newDesc);
9582
11407
  }
11408
+ // `$streams` の値プロパティはインスタンス側の processStreamsDeclaration で実体化されるため、
11409
+ // defineDCC の時点では state 上に descriptor が無い。$bindables に載っているのに
11410
+ // アクセサが生えないと宣言だけが生きて要素側が expando を掴むので、ここで補う(§2.3)。
11411
+ for (const name of streamBackedBindables) {
11412
+ Object.defineProperty(DCCElement.prototype, name, {
11413
+ configurable: true,
11414
+ enumerable: true,
11415
+ get: getterFn(name),
11416
+ set: setterFn(name),
11417
+ });
11418
+ }
9583
11419
  // カスタム要素登録
9584
11420
  customElements.define(tagName, DCCElement);
9585
11421
  }
@@ -9747,6 +11583,56 @@ function dirtyCacheEntryByAbsoluteStateAddress(address) {
9747
11583
  }
9748
11584
  }
9749
11585
 
11586
+ /**
11587
+ * webComponent/crossBoundaryAddress.ts
11588
+ *
11589
+ * mapped な `bind-component` の state は innerState proxy を target に持つ。
11590
+ * そこへの読み書きは `Reflect.get/set(target, path)` で行われるため、Proxy の
11591
+ * トラップに渡るのは**パス文字列だけ**で、解決済みの listIndex が落ちる。
11592
+ *
11593
+ * 子スコープが `for:` でマップ先の配列を回している場合、行バインディングが読む
11594
+ * `items.*.name` は親スコープの `rows.*.name` に翻訳されるが、どの行かは listIndex
11595
+ * にしか無い。ループ文脈はコンポーネント要素(親スコープ側)にぶら下がっており、
11596
+ * 子スコープのループはコンポーネントの内側なので `getLoopContextByNode` では引けない
11597
+ * (docs/architecture-hardening/15-state-component-mechanism-consistency.md §1.8)。
11598
+ *
11599
+ * そこで越境直前のアドレスを動的スコープで受け渡す。push/pop するのは
11600
+ * `hasMappedComponentState` が真の state 要素の読み書きだけで、通常の state の
11601
+ * ホットパス(getByAddress / setByAddress)には一切載らない。
11602
+ */
11603
+ // 行ごとの読み書きで通るため、エントリオブジェクトを割り当てずに 2 本の並行配列で持つ
11604
+ // (リスト描画は「行数 × 行内バインディング数」回ここを通る)。
11605
+ const stateElementStack = [];
11606
+ const addressStack = [];
11607
+ let depth = 0;
11608
+ function pushCrossBoundaryAddress(stateElement, address) {
11609
+ stateElementStack[depth] = stateElement;
11610
+ addressStack[depth] = address;
11611
+ depth++;
11612
+ }
11613
+ function popCrossBoundaryAddress() {
11614
+ depth--;
11615
+ // 参照を残さない(state 要素・listIndex を保持し続けないため)
11616
+ stateElementStack[depth] = undefined;
11617
+ addressStack[depth] = undefined;
11618
+ }
11619
+ /**
11620
+ * 越境直前のアドレスを取り出す。スタック最上位が「この state 要素の、このパスの
11621
+ * 読み書き」であるときだけ返す。ネストしたコンポーネントでは最内の越境が
11622
+ * 最上位になるため、同一性の照合だけで取り違えを防げる。
11623
+ */
11624
+ function getCrossBoundaryAddress(stateElement, path) {
11625
+ if (depth === 0) {
11626
+ return null;
11627
+ }
11628
+ const top = depth - 1;
11629
+ const address = addressStack[top];
11630
+ if (stateElementStack[top] !== stateElement || address?.pathInfo.path !== path) {
11631
+ return null;
11632
+ }
11633
+ return address;
11634
+ }
11635
+
9750
11636
  function checkDependency(handler, address) {
9751
11637
  // $untrackDependency スコープ中/setter 実行中は依存を張らない
9752
11638
  if (handler.untracking) {
@@ -9770,9 +11656,18 @@ function checkDependency(handler, address) {
9770
11656
  if (address.pathInfo.wildcardCount > 0 && lastInfo.wildcardCount > 0) {
9771
11657
  const sharedLen = calcWildcardLen(address.pathInfo, lastInfo);
9772
11658
  if (sharedLen > 0) {
11659
+ // 共有ワイルドカード段の突き合わせ。base 深さ Δ を持つ子スコープでは
11660
+ // 先頭起点だと Δ 段目(=親子で常に同一の base)を比べてしまい、
11661
+ // 本物の他行読み取りを取りこぼす。末尾起点で数える(list/wildcardLevel.ts)
9773
11662
  let crossRow = false;
11663
+ const hereChain = address.listIndex ?? null;
11664
+ const thereChain = lastAddress.listIndex ?? null;
9774
11665
  for (let level = 0; level < sharedLen; level++) {
9775
- if (address.listIndex?.at(level) !== lastAddress.listIndex?.at(level)) {
11666
+ const here = hereChain !== null
11667
+ ? listIndexAtWildcard(hereChain, level, address.pathInfo.wildcardCount) : null;
11668
+ const there = thereChain !== null
11669
+ ? listIndexAtWildcard(thereChain, level, lastInfo.wildcardCount) : null;
11670
+ if (here !== there) {
9776
11671
  crossRow = true;
9777
11672
  break;
9778
11673
  }
@@ -9789,6 +11684,28 @@ function checkDependency(handler, address) {
9789
11684
  }
9790
11685
  }
9791
11686
 
11687
+ /**
11688
+ * このアドレスの値をキャッシュしてよいか(getByAddress / setByAddress 共通の判定)。
11689
+ *
11690
+ * ワイルドカードを含むパス(リスト行)と宣言済み getter は再評価が高くつくため
11691
+ * キャッシュする。ただし mapped な `bind-component` の state は例外で、丸ごと外す。
11692
+ *
11693
+ * mapped な state は値を持たず、読みも書きも親スコープの state へ解決される
11694
+ * (innerState proxy)。同じ値は親側のキャッシュにも載り、その無効化は親の依存 walk が
11695
+ * 担う。子側にもう一段キャッシュを置くと、正本でない複製を親の無効化が届かない場所に
11696
+ * 作ることになり、親起点の書き込みのあと子だけが旧値を読み続ける。二重に持たないのが
11697
+ * 唯一の整合手段なので、mapped な state 要素ではキャッシュ層を持たない — 親の
11698
+ * キャッシュがそのまま効くので、失うのは重複していた一段だけ
11699
+ * (docs/architecture-hardening/15-state-component-mechanism-consistency.md §1.8)。
11700
+ */
11701
+ function isCacheable(stateElement, address) {
11702
+ if (stateElement.hasMappedComponentState === true) {
11703
+ return false;
11704
+ }
11705
+ return address.pathInfo.wildcardCount > 0 ||
11706
+ stateElement.getterPaths.has(address.pathInfo.path);
11707
+ }
11708
+
9792
11709
  /**
9793
11710
  * getByAddress.ts
9794
11711
  *
@@ -9856,6 +11773,17 @@ function _getByAddress(target, address, receiver, handler, stateElement) {
9856
11773
  handler.popAddress();
9857
11774
  }
9858
11775
  }
11776
+ else if (stateElement.hasMappedComponentState === true) {
11777
+ // target は innerState proxy。get トラップにはパス文字列しか渡らないので、
11778
+ // 解決済みの listIndex を動的スコープで越境させる(§1.8)
11779
+ pushCrossBoundaryAddress(stateElement, address);
11780
+ try {
11781
+ return Reflect.get(target, address.pathInfo.path);
11782
+ }
11783
+ finally {
11784
+ popCrossBoundaryAddress();
11785
+ }
11786
+ }
9859
11787
  else {
9860
11788
  return Reflect.get(target, address.pathInfo.path);
9861
11789
  }
@@ -9863,6 +11791,20 @@ function _getByAddress(target, address, receiver, handler, stateElement) {
9863
11791
  else {
9864
11792
  const parentAddress = address.parentAddress ?? raiseError(`address.parentAddress is undefined path: ${address.pathInfo.path}`);
9865
11793
  const parentValue = getByAddress(target, parentAddress, receiver, handler);
11794
+ // 親が居ないパスの読みは undefined(=「state に意見が無い」)。`Reflect.get` に
11795
+ // そのまま渡すと生の `TypeError: Reflect.get called on non-object` になり、
11796
+ // updater の drain も行ループも捕まえないので **1 本の stale な読みが同じバッチの
11797
+ // 無関係な更新まで道連れにする**(§1.7 / §1.9 と同じ構図)。
11798
+ //
11799
+ // 実際に踏むのは「消えた行を指すバインディングが、その行を消す `for` より先に
11800
+ // 適用される」形。同一スコープならトポロジカル順で `for` が先に来るので起きないが、
11801
+ // bind-component は親スコープの通知と子スコープの `for` が別経路で流れるため
11802
+ // 順序が保証されない(docs/state-bind-component-nested-for-design.md)。
11803
+ // undefined はプロパティ書き込みがスキップされる値なので DOM は触られず、
11804
+ // 直後に `for` が行ごと外して整合する。
11805
+ if (parentValue === null || typeof parentValue === "undefined") {
11806
+ return undefined;
11807
+ }
9866
11808
  const lastSegment = address.pathInfo.segments[address.pathInfo.segments.length - 1];
9867
11809
  if (lastSegment === WILDCARD) {
9868
11810
  const index = address.listIndex?.index ?? raiseError(`address.listIndex?.index is undefined path: ${address.pathInfo.path}`);
@@ -9892,8 +11834,7 @@ function getByAddress(target, address, receiver, handler) {
9892
11834
  // $streams の args トレース中のみ絶対アドレスを捕捉(collector 非活性なら即 return)
9893
11835
  collectStreamDependency(handler.stateElement, address);
9894
11836
  const stateElement = handler.stateElement;
9895
- const cacheable = address.pathInfo.wildcardCount > 0 ||
9896
- stateElement.getterPaths.has(address.pathInfo.path);
11837
+ const cacheable = isCacheable(stateElement, address);
9897
11838
  if (cacheable) {
9898
11839
  return _getByAddressWithCache(target, address, receiver, handler, stateElement);
9899
11840
  }
@@ -9931,7 +11872,181 @@ function getContextListIndex(handler, structuredPath) {
9931
11872
  if (typeof index === "undefined") {
9932
11873
  return null;
9933
11874
  }
9934
- return address.listIndex?.at(index) ?? null;
11875
+ if (address.listIndex === null) {
11876
+ return null;
11877
+ }
11878
+ return listIndexAtWildcard(address.listIndex, index, address.pathInfo.wildcardCount);
11879
+ }
11880
+
11881
+ /**
11882
+ * DCC の `$bindables` メンバの変更イベントを host に dispatch する。
11883
+ *
11884
+ * 対応するのは 3 通り。
11885
+ *
11886
+ * 1. **完全一致** — `count = 1` が `count` メンバを撃つ。`detail` は書き込んだ値。
11887
+ * 2. **サブパス** — `user.name = "x"` や `items.0.done = true` が `user` / `items` メンバを撃つ。
11888
+ * `$bindables` のエントリは常にフラットなトップレベル名(dotted 名は
11889
+ * processDccDeclarations の存在検査で落ちる)なので、先頭セグメントを見れば足りる。
11890
+ * この場合 `detail` は付かない — メンバ全体ではない値を載せると誤解を招くため。
11891
+ * 3. **`$postUpdate`** — in-place 変異を通知する正規の idiom。書き込んだ値が無いので `detail` は付かない。
11892
+ *
11893
+ * `detail` に頼らないのが正しい読み方で、`createWcBindable` は各 property に
11894
+ * `getter: (event) => event.target[name]` を宣言している。observer はイベントを
11895
+ * 「変わった」という通知として受け取り、値は要素から読む。
11896
+ *
11897
+ * 従来は完全一致しか見ておらず、`$bindables: ["user"]` で `user.name` を書いても
11898
+ * 発火しなかった。wc-bindable の `properties[].event` は「変更で発火する」契約なので乖離していた
11899
+ * (docs/architecture-hardening/15-state-component-mechanism-consistency.md §2.1)。
11900
+ *
11901
+ * 配列の in-place 変異(`items.push(...)`)は set トラップを通らないため、ここでも捕まらない。
11902
+ * これはリアクティブコア全体の規範(in-place 変異は `$postUpdate` で通知する)と同じで、
11903
+ * 正しい idiom を踏めば 3 で発火する。
11904
+ *
11905
+ * `$listKeys` を宣言したリストは、配列代入がキー突合後に per-path 書き込みへ分解されるため
11906
+ * (docs/state-list-key-design.md §2)、1 回の代入で `1 + 変化行数` 回発火する。値は要素から
11907
+ * 読む契約なので結果は変わらない。詳細は上記 §2.1 の「`$listKeys` との相互作用」。
11908
+ */
11909
+ function dispatchBindableEvent(stateElement, pathInfo, detail) {
11910
+ const map = stateElement.bindableEventMap;
11911
+ const exactEventName = map[pathInfo.path];
11912
+ const isExact = typeof exactEventName === "string";
11913
+ const eventName = isExact
11914
+ ? exactEventName
11915
+ : (pathInfo.segments.length > 1 ? map[pathInfo.segments[0]] : undefined);
11916
+ if (typeof eventName !== "string") {
11917
+ return;
11918
+ }
11919
+ const rootNode = stateElement.rootNode;
11920
+ if (!(rootNode instanceof ShadowRoot)) {
11921
+ return;
11922
+ }
11923
+ rootNode.host.dispatchEvent(new CustomEvent(eventName, {
11924
+ // 完全一致のときだけ、書き込んだ値をそのまま載せる(従来互換)。
11925
+ detail: isExact && typeof detail !== "undefined" ? detail.value : undefined,
11926
+ bubbles: true,
11927
+ }));
11928
+ }
11929
+
11930
+ /**
11931
+ * list/mergeKeyedList.ts
11932
+ *
11933
+ * `$listKeys` 宣言済みリストパスへの配列代入で、キーが一致する行の
11934
+ * 「オブジェクト強制・値展開」を行う(docs/state-list-key-design.md §2)。
11935
+ *
11936
+ * - キー突合し、一致行は**旧オブジェクトを据え置いた**ハイブリッド配列を作る
11937
+ * → 配列要素の参照が変わらないので for は行を再利用する(DOM・フォーカス・
11938
+ * 非バインド DOM 状態が保存される)
11939
+ * - 一致行の「変化したフィールド」だけを列挙して返す
11940
+ * → 呼び出し側が per-path 書き込みとして発行する(§7.0 の穴を塞ぐ正典イディオム)
11941
+ *
11942
+ * このモジュールは純粋な計算のみで、state への書き込みは行わない。
11943
+ */
11944
+ /**
11945
+ * 値展開は own enumerable データプロパティのコピーなので、プロトタイプや
11946
+ * アクセサを持つオブジェクトでは意味論が保てない。plain object 以外は即エラー(§5)。
11947
+ */
11948
+ function assertPlainRow(row, path, side, position) {
11949
+ if (typeof row !== "object" || row === null) {
11950
+ raiseError(`${STATE_LIST_KEYS_NAME} list "${path}": ${side} row at index ${position} must be a plain object ` +
11951
+ `(got ${row === null ? "null" : typeof row}).`);
11952
+ }
11953
+ const proto = Object.getPrototypeOf(row);
11954
+ if (proto !== Object.prototype && proto !== null) {
11955
+ raiseError(`${STATE_LIST_KEYS_NAME} list "${path}": ${side} row at index ${position} must be a plain object ` +
11956
+ `(class instances and exotic objects cannot be value-expanded).`);
11957
+ }
11958
+ }
11959
+ function keyOf(row, spec, path, side, position) {
11960
+ const key = typeof spec === "function" ? spec(row) : row[spec];
11961
+ if (key === undefined || key === null) {
11962
+ raiseError(`${STATE_LIST_KEYS_NAME} list "${path}": ${side} row at index ${position} has no key ` +
11963
+ `(${typeof spec === "function" ? "key function" : `field "${spec}"`} returned ${String(key)}).`);
11964
+ }
11965
+ return key;
11966
+ }
11967
+ /** 行を検証しつつキーを抽出する。キー重複は即エラー(§5)。 */
11968
+ function extractKeys(list, spec, path, side) {
11969
+ const keys = new Array(list.length);
11970
+ const seen = new Set();
11971
+ for (let i = 0; i < list.length; i++) {
11972
+ const row = list[i];
11973
+ assertPlainRow(row, path, side, i);
11974
+ const key = keyOf(row, spec, path, side, i);
11975
+ if (seen.has(key)) {
11976
+ raiseError(`${STATE_LIST_KEYS_NAME} list "${path}": duplicate key ${JSON.stringify(key)} in ${side} list.`);
11977
+ }
11978
+ seen.add(key);
11979
+ keys[i] = key;
11980
+ }
11981
+ return keys;
11982
+ }
11983
+ /**
11984
+ * キー突合してハイブリッド配列を組む。値展開すべき一致行が 1 つも無ければ null
11985
+ * (呼び出し側は従来どおりの書き込みへ倒す)。
11986
+ *
11987
+ * 突合対象の旧配列は「最後に適用された配列」ではなく**現在格納されている配列**。
11988
+ * ハイブリッド構築が格納配列の参照を保存するため、同一マイクロタスク内の連続
11989
+ * 書き込みでも適用時の diff と transitive に整合する(§6)。
11990
+ */
11991
+ function mergeKeyedList(path, spec, oldValue, newList) {
11992
+ // 宣言済みパスなら初回代入(旧配列なし)でも新配列を検証する。
11993
+ // 「2 回目の代入で初めて重複キーが露見する」という不連続を避けるため。
11994
+ const newKeys = extractKeys(newList, spec, path, "new");
11995
+ if (!Array.isArray(oldValue) || oldValue.length === 0 || newList.length === 0) {
11996
+ return null;
11997
+ }
11998
+ const oldList = oldValue;
11999
+ const oldKeys = extractKeys(oldList, spec, path, "current");
12000
+ const oldRowByKey = new Map();
12001
+ for (let i = 0; i < oldList.length; i++) {
12002
+ oldRowByKey.set(oldKeys[i], oldList[i]);
12003
+ }
12004
+ const list = new Array(newList.length);
12005
+ const matched = [];
12006
+ for (let i = 0; i < newList.length; i++) {
12007
+ const newRow = newList[i];
12008
+ const oldRow = oldRowByKey.get(newKeys[i]);
12009
+ if (typeof oldRow === "undefined" || oldRow === newRow) {
12010
+ // 追加行、または既に同一オブジェクト(生配列 in-place 変異 + コピー再代入の
12011
+ // イディオム)。後者は従来どおり walkDependency の全行フォールバックが担う。
12012
+ list[i] = newRow;
12013
+ continue;
12014
+ }
12015
+ list[i] = oldRow;
12016
+ matched.push({ position: i, oldRow, newRow });
12017
+ }
12018
+ return matched.length > 0 ? { list, matched } : null;
12019
+ }
12020
+ /**
12021
+ * 一致行について per-path 書き込みすべきフィールドを列挙する。
12022
+ *
12023
+ * - 変化したフィールドのみ(同値は書かない。無変化リフレッシュを完全なゼロコストに
12024
+ * するため — 全フィールド無条件書き込みだと §2.2 の利得が消える)
12025
+ * - 新行から消えた旧フィールドは **null** を書く(undefined ではない)
12026
+ *
12027
+ * 同値判定は Object.is。setByAddress の same-value guard と同じ基準にすることで、
12028
+ * 「発行したが guard に落とされる」無駄な書き込みを作らない。
12029
+ *
12030
+ * 消えたフィールドに null を使うのは、この処理系では undefined が
12031
+ * 「状態が値を持たない=無意見」であり applyChangeToProperty が書き込みごと
12032
+ * スキップするため(明示的なクリアの語彙は null)。undefined を書くと state 側は
12033
+ * 更新されるのに DOM だけ旧値のまま残り、まさに本機能が塞ごうとしている
12034
+ * stale を再導入してしまう。既に null / undefined のフィールドは DOM 上も
12035
+ * 空なので、クリア書き込み自体を発行しない。
12036
+ */
12037
+ function collectFieldWrites(oldRow, newRow) {
12038
+ const writes = [];
12039
+ for (const field of Object.keys(newRow)) {
12040
+ if (!Object.is(oldRow[field], newRow[field])) {
12041
+ writes.push({ field, value: newRow[field] });
12042
+ }
12043
+ }
12044
+ for (const field of Object.keys(oldRow)) {
12045
+ if (!Object.hasOwn(newRow, field) && oldRow[field] != null) {
12046
+ writes.push({ field, value: null });
12047
+ }
12048
+ }
12049
+ return writes;
9935
12050
  }
9936
12051
 
9937
12052
  /**
@@ -10100,7 +12215,7 @@ function _walkExpandWildcard(context, currentWildcardIndex, parentListIndex) {
10100
12215
  const parentAbsAddress = createAbsoluteStateAddress(parentAbsPathInfo, parentListIndex);
10101
12216
  const lastValue = getLastListValueByAbsoluteStateAddress(parentAbsAddress);
10102
12217
  const newValue = context.stateProxy[getByAddressSymbol](parentAddress);
10103
- const listDiff = createListDiff(parentAddress.listIndex, lastValue, newValue);
12218
+ const listDiff = createListDiff(getListParentListIndex(context.stateElement, parentAddress.listIndex), lastValue, newValue);
10104
12219
  const loopIndexes = getIndexes(listDiff, context.searchType);
10105
12220
  if (currentWildcardIndex === context.wildcardPaths.length - 1) {
10106
12221
  context.targetListIndexes.push(...loopIndexes);
@@ -10130,6 +12245,12 @@ function selectExpansionIndexes(context, sourcePath, _lastValue, _newValue, list
10130
12245
  if (listDiff.addIndexSet.size === 0 && listDiff.changeIndexSet.size === 0) {
10131
12246
  // 追加も移動も無い。削除も無ければ「変化が見えない再代入」= リフレッシュ意図
10132
12247
  if (listDiff.deleteIndexSet.size === 0) {
12248
+ // ただしキー突合による値展開が成立した書き込みでは、変化フィールドは
12249
+ // per-path 書き込みで個別に dirty 化済み。全行展開は純粋な無駄になる
12250
+ // (無変化ポーリングをゼロコストにする — 設計書 §2.2)。
12251
+ if (context.keyedMergePath === sourcePath) {
12252
+ return { fullRows: EMPTY_INDEXES, movedRows: null };
12253
+ }
10133
12254
  return { fullRows: listDiff.newIndexes, movedRows: null };
10134
12255
  }
10135
12256
  // 削除のみ: 残存行は位置も値も不変なので展開しない
@@ -10140,13 +12261,22 @@ function selectExpansionIndexes(context, sourcePath, _lastValue, _newValue, list
10140
12261
  const EMPTY_PATH_INFOS = [];
10141
12262
  /**
10142
12263
  * 位置だけが変わった行(movedRows)で展開すべきパス群を求める。
10143
- * `${listPath}.*` の静的 subtree を辿り、$1 等を読んだ実績のある getter
12264
+ * `${listPath}.*` 配下にある、$1 等を読んだ実績のある getter
10144
12265
  * (indexDependentGetterPaths)だけを返す。行の同一性・listIndex は保たれ
10145
12266
  * index 以外の入力が不変なので、index を読まない getter / 値パスは再評価不要。
10146
12267
  * 戻り値:
10147
12268
  * - IPathInfo[](空可): この各パスだけを行の listIndex で展開する
10148
12269
  * - null: ネストしたワイルドカード配下に index 依存 getter がある
10149
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 より小さい。
10150
12280
  */
10151
12281
  function getMovedRowExpansionPaths(context, wildcardPath, depPathInfo) {
10152
12282
  const indexGetters = context.stateElement.indexDependentGetterPaths;
@@ -10154,26 +12284,16 @@ function getMovedRowExpansionPaths(context, wildcardPath, depPathInfo) {
10154
12284
  return EMPTY_PATH_INFOS;
10155
12285
  }
10156
12286
  let result = null;
10157
- const queue = [wildcardPath];
10158
- const seen = new Set(queue);
10159
- for (let i = 0; i < queue.length; i++) {
10160
- const path = queue[i];
10161
- if (indexGetters.has(path)) {
10162
- const pathInfo = getPathInfo(path);
10163
- if (pathInfo.wildcardCount !== depPathInfo.wildcardCount) {
10164
- return null;
10165
- }
10166
- (result ??= []).push(pathInfo);
10167
- }
10168
- const children = context.staticMap.get(path);
10169
- if (children) {
10170
- for (const child of children) {
10171
- if (!seen.has(child)) {
10172
- seen.add(child);
10173
- queue.push(child);
10174
- }
10175
- }
12287
+ const prefix = wildcardPath + DELIMITER;
12288
+ for (const path of indexGetters) {
12289
+ if (path !== wildcardPath && !path.startsWith(prefix)) {
12290
+ continue;
10176
12291
  }
12292
+ const pathInfo = getPathInfo(path);
12293
+ if (pathInfo.wildcardCount !== depPathInfo.wildcardCount) {
12294
+ return null;
12295
+ }
12296
+ (result ??= []).push(pathInfo);
10177
12297
  }
10178
12298
  return result ?? EMPTY_PATH_INFOS;
10179
12299
  }
@@ -10238,7 +12358,7 @@ function _collectDependencies(context, address, nextEntries) {
10238
12358
  const absPathInfo = getAbsolutePathInfo(context.stateElement, address.pathInfo);
10239
12359
  const absAddress = createAbsoluteStateAddress(absPathInfo, address.listIndex);
10240
12360
  const lastValue = getLastListValueByAbsoluteStateAddress(absAddress);
10241
- const listDiff = createListDiff(address.listIndex, lastValue, newValue);
12361
+ const listDiff = createListDiff(getListParentListIndex(context.stateElement, address.listIndex), lastValue, newValue);
10242
12362
  const selection = selectExpansionIndexes(context, sourcePath, lastValue, newValue, listDiff);
10243
12363
  for (const listIndex of selection.fullRows) {
10244
12364
  const depAddress = createStateAddress(depPathInfo, listIndex);
@@ -10311,7 +12431,7 @@ function _collectDependencies(context, address, nextEntries) {
10311
12431
  if (address.listIndex === null) {
10312
12432
  raiseError(`Cannot expand dynamic dependency with wildcard for non-list address: ${address.pathInfo.path}`);
10313
12433
  }
10314
- listIndex = address.listIndex.at(wildcardLen - 1);
12434
+ listIndex = listIndexAtWildcard(address.listIndex, wildcardLen - 1, address.pathInfo.wildcardCount);
10315
12435
  }
10316
12436
  else {
10317
12437
  // selectedIndex => items.*.selected
@@ -10336,7 +12456,7 @@ function _collectDependencies(context, address, nextEntries) {
10336
12456
  if (address.listIndex === null) {
10337
12457
  raiseError(`Cannot expand dynamic dependency with wildcard for non-list address: ${address.pathInfo.path}`);
10338
12458
  }
10339
- const listIndex = address.listIndex.at(wildcardLen - 1);
12459
+ const listIndex = listIndexAtWildcard(address.listIndex, wildcardLen - 1, address.pathInfo.wildcardCount);
10340
12460
  listIndexes.push(listIndex);
10341
12461
  }
10342
12462
  }
@@ -10378,6 +12498,7 @@ function walkDependency(stateName, stateElement, startAddress, staticDependency,
10378
12498
  stateProxy: stateProxy,
10379
12499
  searchType: searchType,
10380
12500
  listExpansion: options?.listExpansion ?? "full",
12501
+ keyedMergePath: options?.keyedMergePath ?? null,
10381
12502
  };
10382
12503
  _walkDependency(context, startAddress, callback);
10383
12504
  return Array.from(context.result);
@@ -10400,11 +12521,26 @@ function walkDependency(stateName, stateElement, startAddress, staticDependency,
10400
12521
  * - finallyで必ず更新情報を登録し、再描画や依存解決に利用
10401
12522
  * - getter/setter経由のスコープ切り替えも考慮した設計
10402
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
+ }
10403
12539
  // Phase 3: 書き込み時点の因果 context を update record に付与する。
10404
12540
  // binding 経由の書き込みは呼び出し元の dynamic scope から context を引き継ぎ、
10405
12541
  // binding 外からの API update は新しい transaction を開始する(設計書 §4 規則 1)。
10406
12542
  // 依存 walk で enqueue される派生アドレスも同じ書き込みの因果に属する。
10407
- function notifyWrite(address, absAddress, receiver, handler) {
12543
+ function notifyWrite(address, absAddress, receiver, handler, keyedMergePath) {
10408
12544
  const propagationContext = config.enablePropagationContext
10409
12545
  ? (getCurrentPropagationContext() ?? beginPropagationTransaction(-1))
10410
12546
  : null;
@@ -10423,9 +12559,9 @@ function notifyWrite(address, absAddress, receiver, handler) {
10423
12559
  },
10424
12560
  // リスト置換時は追加行・位置変更行のみ展開する(未変更行の再訪を省く。
10425
12561
  // $postUpdate の手動リフレッシュは従来通り全行展開のまま)
10426
- { listExpansion: "diff" });
12562
+ { listExpansion: "diff", keyedMergePath });
10427
12563
  }
10428
- function _setByAddress(target, address, absAddress, value, receiver, handler) {
12564
+ function _setByAddress(target, address, absAddress, value, receiver, handler, keyedMergePath) {
10429
12565
  try {
10430
12566
  if (address.pathInfo.path in target) {
10431
12567
  if (handler.stateElement.setterPaths.has(address.pathInfo.path)) {
@@ -10444,6 +12580,17 @@ function _setByAddress(target, address, absAddress, value, receiver, handler) {
10444
12580
  handler.popAddress();
10445
12581
  }
10446
12582
  }
12583
+ else if (handler.stateElement.hasMappedComponentState === true) {
12584
+ // target は innerState proxy。set トラップにはパス文字列しか渡らないので、
12585
+ // 解決済みの listIndex を動的スコープで越境させる(§1.8)
12586
+ pushCrossBoundaryAddress(handler.stateElement, address);
12587
+ try {
12588
+ return Reflect.set(target, address.pathInfo.path, value);
12589
+ }
12590
+ finally {
12591
+ popCrossBoundaryAddress();
12592
+ }
12593
+ }
10447
12594
  else {
10448
12595
  return Reflect.set(target, address.pathInfo.path, value);
10449
12596
  }
@@ -10465,10 +12612,10 @@ function _setByAddress(target, address, absAddress, value, receiver, handler) {
10465
12612
  }
10466
12613
  }
10467
12614
  finally {
10468
- notifyWrite(address, absAddress, receiver, handler);
12615
+ notifyWrite(address, absAddress, receiver, handler, keyedMergePath);
10469
12616
  }
10470
12617
  }
10471
- function _setByAddressWithSwap(target, address, absAddress, value, receiver, handler) {
12618
+ function _setByAddressWithSwap(target, address, absAddress, value, receiver, handler, keyedMergePath) {
10472
12619
  // elementsの場合はswapInfoを準備
10473
12620
  let parentAddress = address.parentAddress ?? raiseError(`address.parentAddress is undefined path: ${address.pathInfo.path}`);
10474
12621
  let swapInfo = getSwapInfoByAddress(parentAddress);
@@ -10481,7 +12628,7 @@ function _setByAddressWithSwap(target, address, absAddress, value, receiver, han
10481
12628
  setSwapInfoByAddress(parentAddress, swapInfo);
10482
12629
  }
10483
12630
  try {
10484
- return _setByAddress(target, address, absAddress, value, receiver, handler);
12631
+ return _setByAddress(target, address, absAddress, value, receiver, handler, keyedMergePath);
10485
12632
  }
10486
12633
  finally {
10487
12634
  const index = swapInfo.value.indexOf(value);
@@ -10504,7 +12651,80 @@ function _setByAddressWithSwap(target, address, absAddress, value, receiver, han
10504
12651
  }
10505
12652
  }
10506
12653
  }
12654
+ /**
12655
+ * `$listKeys` 宣言済みリストパスへの配列代入を「キー一致行のオブジェクト値展開」に
12656
+ * 変換する(docs/state-list-key-design.md §2)。
12657
+ *
12658
+ * 1. キー突合して、一致行は旧オブジェクトを据え置いたハイブリッド配列を作る
12659
+ * 2. ハイブリッド配列を通常の書き込み経路で格納する
12660
+ * 3. createListDiff で listIndex を確定し、変化フィールドだけを per-path 書き込みで発行
12661
+ *
12662
+ * 3 を格納後に行うのが要点。フィールド書き込みは `list.*.field` を親経由で解決する
12663
+ * ため、親(ハイブリッド配列)が既に格納されていなければ正しい行に届かない。
12664
+ * また per-path 書き込みは再び setByAddress に入るので、ネストしたリストパスが
12665
+ * 宣言されていればそのレベルのキー突合が再帰的に走る(§4)。
12666
+ *
12667
+ * 未宣言時のコストは stateElement.listKeys の null 判定 1 回のみ(§7-1)。
12668
+ */
12669
+ function setKeyedListByAddress(target, address, merge, oldList, receiver, handler) {
12670
+ const listPath = address.pathInfo.path;
12671
+ // diff の基準は「マージ相手にした配列」= 書き込み直前に格納されていた配列。
12672
+ // 読み手(applyChangeToFor / $getAll / resolve)は現在格納されている配列の
12673
+ // listIndex 台帳(listIndexesByList)へ収束するため、同じ基準で引くことで
12674
+ // 書き込みが dirty 化・キャッシュするアドレスと読み手のアドレスが一致する。
12675
+ // lastValue(最後に *適用* された配列)を基準にすると、for が未マウントで
12676
+ // lastValue が空のときに別台帳を作ってしまい、値は入っているのにワイルドカード
12677
+ // 読みだけ旧値のまま残る(設計書 §8.1)。
12678
+ // 格納より前に引くのは、格納時の walkDependency(listExpansion: "diff")が
12679
+ // 先にハイブリッド配列の台帳を作ってしまうと、後から上書きした台帳との間で
12680
+ // 同じ分裂が起きるため。先に確定させておけば以降は全経路がこれに合流する。
12681
+ const listParentListIndex = getListParentListIndex(handler.stateElement, address.listIndex);
12682
+ if (getListIndexesByList(oldList) === null) {
12683
+ // 一度も描画されていないリストは台帳自体が無い。先に生やしておかないと
12684
+ // isSameList 経路が空の oldIndexes をそのまま新台帳にしてしまう。
12685
+ createListDiff(listParentListIndex, null, oldList);
12686
+ }
12687
+ const diff = createListDiff(listParentListIndex, oldList, merge.list);
12688
+ const result = setByAddressCore(target, address, merge.list, receiver, handler, listPath);
12689
+ const elementPathInfo = getPathInfo(listPath + DELIMITER + WILDCARD);
12690
+ for (const match of merge.matched) {
12691
+ const fieldWrites = collectFieldWrites(match.oldRow, match.newRow);
12692
+ if (fieldWrites.length === 0) {
12693
+ continue;
12694
+ }
12695
+ // createListDiff の契約上 newIndexes の長さはハイブリッド配列と一致するため
12696
+ // 通常 undefined にはならない。仮に不変条件が破れても、per-path 書き込みを
12697
+ // 諦めるだけで値そのものは行オブジェクトへ反映する(skip すると state だけが
12698
+ // 旧値のまま残り、本機能が塞ごうとしている stale を作ってしまう)。
12699
+ const listIndex = diff.newIndexes[match.position];
12700
+ for (const write of fieldWrites) {
12701
+ if (typeof listIndex === "undefined") {
12702
+ match.oldRow[write.field] = write.value;
12703
+ continue;
12704
+ }
12705
+ const fieldPathInfo = getPathInfo(elementPathInfo.path + DELIMITER + write.field);
12706
+ const fieldAddress = createStateAddress(fieldPathInfo, listIndex);
12707
+ setByAddress(target, fieldAddress, write.value, receiver, handler);
12708
+ }
12709
+ }
12710
+ return result;
12711
+ }
10507
12712
  function setByAddress(target, address, value, receiver, handler) {
12713
+ const listKeys = handler.stateElement.listKeys;
12714
+ if (listKeys != null && Array.isArray(value)) {
12715
+ const keySpec = listKeys.get(address.pathInfo.path);
12716
+ if (typeof keySpec !== "undefined") {
12717
+ const oldValue = getByAddress(target, address, receiver, handler);
12718
+ const merge = mergeKeyedList(address.pathInfo.path, keySpec, oldValue, value);
12719
+ if (merge !== null) {
12720
+ // merge が非 null なのは oldValue が非空配列のときだけ(mergeKeyedList 参照)
12721
+ return setKeyedListByAddress(target, address, merge, oldValue, receiver, handler);
12722
+ }
12723
+ }
12724
+ }
12725
+ return setByAddressCore(target, address, value, receiver, handler, null);
12726
+ }
12727
+ function setByAddressCore(target, address, value, receiver, handler, keyedMergePath) {
10508
12728
  const stateElement = handler.stateElement;
10509
12729
  const path = address.pathInfo.path;
10510
12730
  // occurrence(wc-bindable の `semantics: "event"`)由来の書き込みは、同値でも
@@ -10537,8 +12757,7 @@ function setByAddress(target, address, value, receiver, handler) {
10537
12757
  devOldValue = oldValue;
10538
12758
  devHasOldValue = true;
10539
12759
  }
10540
- const cacheable = address.pathInfo.wildcardCount > 0 ||
10541
- stateElement.getterPaths.has(path);
12760
+ const cacheable = isCacheable(stateElement, address);
10542
12761
  const absPathInfo = getAbsolutePathInfo(stateElement, address.pathInfo);
10543
12762
  const absAddress = createAbsoluteStateAddress(absPathInfo, address.listIndex);
10544
12763
  if (devtoolsSink !== null) {
@@ -10550,6 +12769,7 @@ function setByAddress(target, address, value, receiver, handler) {
10550
12769
  hasOldValue: devHasOldValue,
10551
12770
  });
10552
12771
  }
12772
+ recordWatchPrevValue(stateElement, path, absAddress, devOldValue, devHasOldValue);
10553
12773
  try {
10554
12774
  if (key === undefined) {
10555
12775
  raiseError(`address.listIndex?.index is undefined path: ${path}`);
@@ -10557,24 +12777,15 @@ function setByAddress(target, address, value, receiver, handler) {
10557
12777
  return Reflect.set(parentValue, key, value);
10558
12778
  }
10559
12779
  finally {
10560
- notifyWrite(address, absAddress, receiver, handler);
12780
+ notifyWrite(address, absAddress, receiver, handler, keyedMergePath);
10561
12781
  if (cacheable) {
10562
12782
  setCacheEntryByAbsoluteStateAddress(absAddress, {
10563
12783
  value: value,
10564
12784
  dirty: false
10565
12785
  });
10566
12786
  }
10567
- // DCC bindable イベントディスパッチ
10568
- const eventName = stateElement.bindableEventMap[path];
10569
- if (eventName) {
10570
- const rootNode = stateElement.rootNode;
10571
- if (rootNode instanceof ShadowRoot) {
10572
- rootNode.host.dispatchEvent(new CustomEvent(eventName, {
10573
- detail: value,
10574
- bubbles: true,
10575
- }));
10576
- }
10577
- }
12787
+ // DCC bindable イベントディスパッチ(完全一致 + サブパス → 先頭セグメント、§2.1)
12788
+ dispatchBindableEvent(stateElement, address.pathInfo, { value });
10578
12789
  }
10579
12790
  }
10580
12791
  }
@@ -10597,8 +12808,7 @@ function setByAddress(target, address, value, receiver, handler) {
10597
12808
  }
10598
12809
  // --- end same-value guard ---
10599
12810
  const isSwappable = stateElement.elementPaths.has(address.pathInfo.path);
10600
- const cacheable = address.pathInfo.wildcardCount > 0 ||
10601
- stateElement.getterPaths.has(address.pathInfo.path);
12811
+ const cacheable = isCacheable(stateElement, address);
10602
12812
  const absPathInfo = getAbsolutePathInfo(stateElement, address.pathInfo);
10603
12813
  const absAddress = createAbsoluteStateAddress(absPathInfo, address.listIndex);
10604
12814
  if (devtoolsSink !== null) {
@@ -10610,12 +12820,13 @@ function setByAddress(target, address, value, receiver, handler) {
10610
12820
  hasOldValue: devHasOldValue,
10611
12821
  });
10612
12822
  }
12823
+ recordWatchPrevValue(stateElement, path, absAddress, devOldValue, devHasOldValue);
10613
12824
  try {
10614
12825
  if (isSwappable) {
10615
- return _setByAddressWithSwap(target, address, absAddress, value, receiver, handler);
12826
+ return _setByAddressWithSwap(target, address, absAddress, value, receiver, handler, keyedMergePath);
10616
12827
  }
10617
12828
  else {
10618
- return _setByAddress(target, address, absAddress, value, receiver, handler);
12829
+ return _setByAddress(target, address, absAddress, value, receiver, handler, keyedMergePath);
10619
12830
  }
10620
12831
  }
10621
12832
  finally {
@@ -10625,17 +12836,8 @@ function setByAddress(target, address, value, receiver, handler) {
10625
12836
  dirty: false
10626
12837
  });
10627
12838
  }
10628
- // DCC bindable イベントディスパッチ
10629
- const eventName = stateElement.bindableEventMap[address.pathInfo.path];
10630
- if (eventName) {
10631
- const rootNode = stateElement.rootNode;
10632
- if (rootNode instanceof ShadowRoot) {
10633
- rootNode.host.dispatchEvent(new CustomEvent(eventName, {
10634
- detail: value,
10635
- bubbles: true,
10636
- }));
10637
- }
10638
- }
12839
+ // DCC bindable イベントディスパッチ(完全一致 + サブパス → 先頭セグメント、§2.1)
12840
+ dispatchBindableEvent(stateElement, address.pathInfo, { value });
10639
12841
  }
10640
12842
  }
10641
12843
 
@@ -10683,8 +12885,10 @@ function resolve(target, _prop, receiver, handler) {
10683
12885
  raiseError(`ListIndexes not found: ${wildcardParentPathInfo.path}`);
10684
12886
  }
10685
12887
  const index = indexes[i];
12888
+ // 範囲外 index はリスト自体の不在と別原因なので index を含める
12889
+ // (docs/state-bind-component-nested-for-design.md §8.4)
10686
12890
  listIndex = listIndexes[index] ??
10687
- raiseError(`ListIndex not found: ${wildcardParentPathInfo.path}`);
12891
+ raiseError(`ListIndex not found at index ${index} of ${wildcardParentPathInfo.path}`);
10688
12892
  }
10689
12893
  // ToDo:WritableかReadonlyかを判定して適切なメソッドを呼び出す
10690
12894
  const address = createStateAddress(pathInfo, listIndex);
@@ -10726,7 +12930,7 @@ function getAll(target, prop, receiver, handler) {
10726
12930
  const wildcardPattern = pathInfo.wildcardParentPathInfos[i];
10727
12931
  const listIndex = getContextListIndex(handler, wildcardPattern.path);
10728
12932
  if (listIndex) {
10729
- indexes = listIndex.indexes;
12933
+ indexes = getScopedIndexes(listIndex, listIndex.length - getBaseDepth(handler.stateElement));
10730
12934
  break;
10731
12935
  }
10732
12936
  }
@@ -10743,7 +12947,7 @@ function getAll(target, prop, receiver, handler) {
10743
12947
  const wildcardAddress = createStateAddress(wildcardParentPathInfo, listIndex);
10744
12948
  const oldValue = lastValueByListAddress.get(wildcardAddress);
10745
12949
  const newValue = getByAddress(target, wildcardAddress, receiver, handler);
10746
- const listDiff = createListDiff(listIndex, oldValue, newValue);
12950
+ const listDiff = createListDiff(getListParentListIndex(handler.stateElement, listIndex), oldValue, newValue);
10747
12951
  const listIndexes = listDiff.newIndexes;
10748
12952
  const index = indexes[indexPos] ?? null;
10749
12953
  newValueByAddress.set(wildcardAddress, newValue);
@@ -10754,8 +12958,10 @@ function getAll(target, prop, receiver, handler) {
10754
12958
  }
10755
12959
  }
10756
12960
  else {
12961
+ // 範囲外 index はリスト自体の不在と別原因なので index を含める
12962
+ // (docs/state-bind-component-nested-for-design.md §8.4)
10757
12963
  const listIndex = listIndexes[index] ??
10758
- raiseError(`ListIndex not found: ${wildcardParentPathInfo.path}`);
12964
+ raiseError(`ListIndex not found at index ${index} of ${wildcardParentPathInfo.path}`);
10759
12965
  if ((wildcardIndexPos + 1) < wildcardParentPathInfos.length) {
10760
12966
  walkWildcardPattern(wildcardParentPathInfos, wildcardIndexPos + 1, listIndex, indexes, indexPos + 1, parentIndexes.concat(listIndex.index), results);
10761
12967
  }
@@ -10817,8 +13023,11 @@ function getListIndex(target, resolvedAddress, receiver, handler) {
10817
13023
  raiseError(`ListIndex not found: ${wildcardParentPathInfo.path}`);
10818
13024
  const wildcardIndex = resolvedAddress.wildcardIndexes[i] ??
10819
13025
  raiseError(`wildcardIndex is null: ${resolvedAddress.pathInfo.path}`);
13026
+ // 範囲外 index はリスト自体の不在と別原因なので、メッセージに index を含める。
13027
+ // 親パスだけを名指しすると「リスト自体が見つからない」と誤読させる
13028
+ // (docs/state-bind-component-nested-for-design.md §8.4)。
10820
13029
  parentListIndex = wildcardParentListIndexes[wildcardIndex] ??
10821
- raiseError(`ListIndex not found: ${wildcardParentPathInfo.path}`);
13030
+ raiseError(`ListIndex not found at index ${wildcardIndex} of ${wildcardParentPathInfo.path}`);
10822
13031
  }
10823
13032
  return parentListIndex;
10824
13033
  }
@@ -10847,6 +13056,10 @@ function postUpdate(target, _prop, receiver, handler) {
10847
13056
  // 更新対象として登録
10848
13057
  updater.enqueueAbsoluteAddress(absDepAddress);
10849
13058
  });
13059
+ // DCC bindable イベントディスパッチ。$postUpdate は in-place 変異を通知する正規の idiom で、
13060
+ // set トラップを通らない変更が観測面に出る唯一の経路なので、ここでも撃つ
13061
+ // (docs/architecture-hardening/15-state-component-mechanism-consistency.md §2.1)。
13062
+ dispatchBindableEvent(stateElement, address.pathInfo);
10850
13063
  };
10851
13064
  }
10852
13065
 
@@ -10951,7 +13164,7 @@ function updatedCallback(target, refs, receiver, handler) {
10951
13164
  }
10952
13165
  paths.add(pathName);
10953
13166
  if (pathInfo.wildcardCount > 0) {
10954
- const indexes = ref.listIndex.indexes ?? [];
13167
+ const indexes = getScopedIndexes(ref.listIndex, pathInfo.wildcardCount);
10955
13168
  const indexesList = indexesListByPath[pathName];
10956
13169
  if (typeof indexesList === "undefined") {
10957
13170
  indexesListByPath[pathName] = [indexes];
@@ -11068,7 +13281,13 @@ function get(target, prop, receiver, handler) {
11068
13281
  handler.stateElement.addIndexDependentGetterPath?.(lastInfo.path);
11069
13282
  }
11070
13283
  const listIndex = lastAddress?.listIndex;
11071
- return listIndex?.indexes[index] ?? raiseError(`ListIndex not found: ${prop.toString()}`);
13284
+ if (typeof listIndex === "undefined" || listIndex === null) {
13285
+ raiseError(`ListIndex not found: ${prop.toString()}`);
13286
+ }
13287
+ // `$1` は「このスコープの」1 段目。base 深さ Δ を持つ子スコープでも
13288
+ // 番号がずれないよう末尾から数える(list/wildcardLevel.ts)
13289
+ const indexListIndex = listIndexAtWildcard(listIndex, index, lastAddress.pathInfo.wildcardCount);
13290
+ return indexListIndex?.index ?? raiseError(`ListIndex not found: ${prop.toString()}`);
11072
13291
  }
11073
13292
  if (typeof prop === "string") {
11074
13293
  if (prop[0] === '$') {
@@ -11305,138 +13524,6 @@ function createStateProxy(rootNode, state, stateName, mutability) {
11305
13524
  return stateProxy;
11306
13525
  }
11307
13526
 
11308
- // WebComponent専用のキャッシュ
11309
- // outerState.tsからのアクセスで、これを返す
11310
- const lastValueByAbsoluteStateAddress = new WeakMap();
11311
- function setLastValueByAbsoluteStateAddress(absoluteStateAddress, value) {
11312
- lastValueByAbsoluteStateAddress.set(absoluteStateAddress, value);
11313
- }
11314
- function getLastValueByAbsoluteStateAddress(absoluteStateAddress) {
11315
- return lastValueByAbsoluteStateAddress.get(absoluteStateAddress);
11316
- }
11317
-
11318
- const stateElementByWebComponent = new WeakMap();
11319
- function setStateElementByWebComponent(webComponent, stateName, stateElement) {
11320
- let stateMap = stateElementByWebComponent.get(webComponent);
11321
- if (!stateMap) {
11322
- stateMap = new Map();
11323
- stateElementByWebComponent.set(webComponent, stateMap);
11324
- }
11325
- stateMap.set(stateName, stateElement);
11326
- }
11327
- function getStateElementByWebComponent(webComponent, stateName) {
11328
- const stateMap = stateElementByWebComponent.get(webComponent);
11329
- if (!stateMap) {
11330
- return null;
11331
- }
11332
- return stateMap.get(stateName) ?? null;
11333
- }
11334
-
11335
- const innerMappingByElement = new WeakMap();
11336
- const outerMappingByElement = new WeakMap();
11337
- const primaryMappingRuleSetByElement = new WeakMap();
11338
- const primaryBindingByMappingRule = new WeakMap();
11339
- function createMappingRuleByBinding(innerState, binding) {
11340
- const innerPathInfo = getPathInfo(binding.propSegments.slice(1).join(DELIMITER));
11341
- const innerAbsPathInfo = getAbsolutePathInfo(innerState, innerPathInfo);
11342
- const outerAbsStateAddress = getAbsoluteStateAddressByBinding(binding);
11343
- const outerAbsPathInfo = outerAbsStateAddress.absolutePathInfo;
11344
- return { innerAbsPathInfo, outerAbsPathInfo };
11345
- }
11346
- function buildPrimaryMappingRule(webComponent, stateName, bindings) {
11347
- if (bindings.length === 0) {
11348
- return;
11349
- }
11350
- const innerState = getStateElementByWebComponent(webComponent, stateName);
11351
- if (innerState === null) {
11352
- raiseError('State element not found for web component.');
11353
- }
11354
- const innerMappingRule = new Map();
11355
- const outerMappingRule = new Map();
11356
- for (const binding of bindings) {
11357
- const mappingRule = createMappingRuleByBinding(innerState, binding);
11358
- let primaryMappingRuleSet = primaryMappingRuleSetByElement.get(webComponent);
11359
- if (typeof primaryMappingRuleSet === 'undefined') {
11360
- primaryMappingRuleSetByElement.set(webComponent, new Set([mappingRule]));
11361
- }
11362
- else {
11363
- primaryMappingRuleSet.add(mappingRule);
11364
- }
11365
- const innerAbsPathInfo = mappingRule.innerAbsPathInfo;
11366
- const outerAbsPathInfo = mappingRule.outerAbsPathInfo;
11367
- primaryBindingByMappingRule.set(mappingRule, binding);
11368
- innerMappingRule.set(innerAbsPathInfo, outerAbsPathInfo);
11369
- outerMappingRule.set(outerAbsPathInfo, innerAbsPathInfo);
11370
- }
11371
- innerMappingByElement.set(webComponent, innerMappingRule);
11372
- outerMappingByElement.set(webComponent, outerMappingRule);
11373
- }
11374
- function getOuterAbsolutePathInfo(webComponent, innerAbsPathInfo) {
11375
- let innerMapping = innerMappingByElement.get(webComponent);
11376
- if (typeof innerMapping === 'undefined') {
11377
- innerMapping = new Map();
11378
- innerMappingByElement.set(webComponent, innerMapping);
11379
- }
11380
- if (innerMapping.has(innerAbsPathInfo)) {
11381
- return innerMapping.get(innerAbsPathInfo);
11382
- }
11383
- let outerMapping = outerMappingByElement.get(webComponent);
11384
- if (typeof outerMapping === 'undefined') {
11385
- outerMapping = new Map();
11386
- outerMappingByElement.set(webComponent, outerMapping);
11387
- }
11388
- // 内側からのアクセスの場合、ルールがなければプライマリルールから新たにルールとバインディングを生成する
11389
- const primaryMappingRuleSet = primaryMappingRuleSetByElement.get(webComponent);
11390
- if (typeof primaryMappingRuleSet === 'undefined') {
11391
- // マッピングルールが存在しない場合はnullを返し、ローカル状態へのフォールバックを許可する
11392
- return null;
11393
- }
11394
- let primaryMappingRule = null;
11395
- for (const currentPrimaryMappingRule of primaryMappingRuleSet) {
11396
- // innerPathInfoがprimaryMappingRuleのinnerPathInfoを包含しているか
11397
- if (!innerAbsPathInfo.pathInfo.cumulativePathInfoSet.has(currentPrimaryMappingRule.innerAbsPathInfo.pathInfo)) {
11398
- continue;
11399
- }
11400
- if (currentPrimaryMappingRule.innerAbsPathInfo.pathInfo.segments.length === innerAbsPathInfo.pathInfo.segments.length) {
11401
- raiseError('Duplicate mapping rule for web component.');
11402
- }
11403
- primaryMappingRule = currentPrimaryMappingRule;
11404
- break;
11405
- }
11406
- if (primaryMappingRule === null) {
11407
- // マッピングルールに一致しない場合はnullを返し、ローカル状態へのフォールバックを許可する
11408
- return null;
11409
- }
11410
- // マッチした残りのパスをouterPathInfoに付与して新たなルールを生成
11411
- const primaryBinding = primaryBindingByMappingRule.get(primaryMappingRule);
11412
- /* c8 ignore start */
11413
- if (typeof primaryBinding === 'undefined') {
11414
- raiseError('Binding not found for primary mapping rule on web component.');
11415
- }
11416
- /* c8 ignore stop */
11417
- const outerRemainingSegments = innerAbsPathInfo.pathInfo.segments.slice(primaryMappingRule.innerAbsPathInfo.pathInfo.segments.length);
11418
- const outerSegments = primaryMappingRule.outerAbsPathInfo.pathInfo.segments.concat(outerRemainingSegments);
11419
- const outerPathInfo = getPathInfo(outerSegments.join(DELIMITER));
11420
- const rootNode = webComponent.getRootNode();
11421
- const outerStateElement = getStateElementByName(rootNode, primaryBinding.stateName);
11422
- if (outerStateElement === null) {
11423
- raiseError(`State element with name "${primaryBinding.stateName}" not found for web component.`);
11424
- }
11425
- const outerAbsPathInfo = getAbsolutePathInfo(outerStateElement, outerPathInfo);
11426
- innerMapping.set(innerAbsPathInfo, outerAbsPathInfo);
11427
- outerMapping.set(outerAbsPathInfo, innerAbsPathInfo);
11428
- // ルールに対応するバインディングを生成
11429
- const newBinding = {
11430
- ...primaryBinding,
11431
- propName: innerAbsPathInfo.pathInfo.path,
11432
- propSegments: innerAbsPathInfo.pathInfo.segments,
11433
- statePathName: outerAbsPathInfo.pathInfo.path,
11434
- statePathInfo: outerAbsPathInfo.pathInfo,
11435
- };
11436
- addBindingByNode(webComponent, newBinding);
11437
- return outerAbsPathInfo;
11438
- }
11439
-
11440
13527
  function cloneWithDescriptors(obj) {
11441
13528
  const proto = Object.getPrototypeOf(obj);
11442
13529
  const clone = Object.create(proto);
@@ -11461,6 +13548,44 @@ class InnerStateProxyHandler {
11461
13548
  this._webComponent = webComponent;
11462
13549
  this._innerStateElement = getStateElementByWebComponent(webComponent, stateName) ?? raiseError('State element not found for web component.');
11463
13550
  }
13551
+ /**
13552
+ * 親スコープで読み書きするときのループ文脈を決める。候補は 2 つある。
13553
+ *
13554
+ * 1. **越境直前のアドレスの listIndex**。子スコープの `for` が回している行
13555
+ * (§1.8)。子の listIndex は base(=ホストの親スコープ行)を親に持つので
13556
+ * チェーン長は Δ+W_inner = W_outer になり、そのまま外側の文脈として使える
13557
+ * (docs/state-bind-component-nested-for-design.md)。
13558
+ * 2. **コンポーネント要素のノードループ文脈**。コンポーネント自身が親の `for` の
13559
+ * 中にいるが、読んでいるパスは子スコープのループの外という形(`state.row: rows.*`)。
13560
+ *
13561
+ * 1 を先に見るのは、内側ほど具体的だから。入れ子形では 2 も非 null(=Δ 段だけ)に
13562
+ * なるが、それでは外側パスの段数に足りない。段数が一致する候補だけを採るのが
13563
+ * 判定の本体で、両方外れたら null(親側の解決に委ね、解けなければ raiseError。
13564
+ * 無言の取り違えを作らない)。
13565
+ */
13566
+ _outerLoopContext(innerPathInfo, outerAbsPathInfo) {
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);
13573
+ const nodeLoopContext = getLoopContextByNode(this._webComponent);
13574
+ if (nodeLoopContext !== null && nodeLoopContext.listIndex.length === outerArity) {
13575
+ return nodeLoopContext;
13576
+ }
13577
+ if (outerWildcardCount > 0) {
13578
+ const address = getCrossBoundaryAddress(this._innerStateElement, innerPathInfo.path);
13579
+ const listIndex = address?.listIndex ?? null;
13580
+ if (listIndex !== null && listIndex.length === outerArity) {
13581
+ const outerWildcardPath = outerAbsPathInfo.pathInfo.wildcardPaths[outerWildcardCount - 1];
13582
+ return createStateAddress(getPathInfo(outerWildcardPath), listIndex);
13583
+ }
13584
+ }
13585
+ // どちらも段数が合わない。従来どおりノードの文脈へフォールバックし、
13586
+ // 解けなければ後段が raiseError する(無言の取り違えを作らない)
13587
+ return nodeLoopContext;
13588
+ }
11464
13589
  get(target, prop, receiver) {
11465
13590
  if (typeof prop === 'string') {
11466
13591
  if (prop === "then") {
@@ -11479,21 +13604,11 @@ class InnerStateProxyHandler {
11479
13604
  const innerAbsPathInfo = getAbsolutePathInfo(this._innerStateElement, innerPathInfo);
11480
13605
  const outerAbsPathInfo = getOuterAbsolutePathInfo(this._webComponent, innerAbsPathInfo);
11481
13606
  if (outerAbsPathInfo !== null) {
11482
- const loopContext = getLoopContextByNode(this._webComponent);
13607
+ const loopContext = this._outerLoopContext(innerPathInfo, outerAbsPathInfo);
11483
13608
  let value = undefined;
11484
13609
  outerAbsPathInfo.stateElement.createState("readonly", (state) => {
11485
13610
  state[setLoopContextSymbol](loopContext, () => {
11486
13611
  value = state[outerAbsPathInfo.pathInfo.path];
11487
- let listIndex = null;
11488
- if (loopContext !== null && loopContext.listIndex !== null) {
11489
- if (outerAbsPathInfo.pathInfo.wildcardCount > 0) {
11490
- // wildcardPathSetとloopContextのpathInfoSetのintersectionのうち、segment数が最も多いものをouterAbsPathInfoにする
11491
- // 例: outerPathInfoが "todos.*.name"で、loopContextのpathInfoSetに "todos.0.name", "todos.1.name"がある場合、"todos.0.name"や"todos.1.name"をouterAbsPathInfoにする
11492
- listIndex = loopContext.listIndex.at(outerAbsPathInfo.pathInfo.wildcardCount - 1);
11493
- }
11494
- }
11495
- const absStateAddress = createAbsoluteStateAddress(outerAbsPathInfo, listIndex);
11496
- setLastValueByAbsoluteStateAddress(absStateAddress, value);
11497
13612
  });
11498
13613
  });
11499
13614
  return value;
@@ -11520,7 +13635,7 @@ class InnerStateProxyHandler {
11520
13635
  const innerAbsPathInfo = getAbsolutePathInfo(this._innerStateElement, innerPathInfo);
11521
13636
  const outerAbsPathInfo = getOuterAbsolutePathInfo(this._webComponent, innerAbsPathInfo);
11522
13637
  if (outerAbsPathInfo !== null) {
11523
- const loopContext = getLoopContextByNode(this._webComponent);
13638
+ const loopContext = this._outerLoopContext(innerPathInfo, outerAbsPathInfo);
11524
13639
  outerAbsPathInfo.stateElement.createState("writable", (state) => {
11525
13640
  state[setLoopContextSymbol](loopContext, () => {
11526
13641
  state[outerAbsPathInfo.pathInfo.path] = value;
@@ -11588,42 +13703,24 @@ function createInnerState(webComponent, stateName) {
11588
13703
  return new Proxy(meltFrozenObject(state), handler);
11589
13704
  }
11590
13705
 
13706
+ /**
13707
+ * コンポーネントの `bind-component` プロパティとして露出する proxy。
13708
+ * read / write とも子の state proxy へ素通しする。
13709
+ *
13710
+ * mapped(親から `<prop>.*` をバインドされている)ケースでは、素通し先の
13711
+ * innerState proxy がマッピング規則に従って親 state へ解決するので、
13712
+ * `this.state.msg` の読みは親の現在値になり、書きは親 state へ届く。
13713
+ * plain(親からのバインドなし)ケースでは子のローカル state に解決する。
13714
+ * **どちらでも同じ意味論になる**のが要点
13715
+ * (docs/architecture-hardening/15-state-component-mechanism-consistency.md §1.1 / G1)。
13716
+ *
13717
+ * 以前は mapped 専用に「read = 最後に観測した値のキャッシュ/write = 値を捨てて
13718
+ * `$postUpdate` 通知のみ」という別 proxy を当てていた。あれは親 → 子の再読込通知という
13719
+ * **内部チャネル**としては正しかったが、それが公開 API を兼ねていたため、同じ
13720
+ * コンポーネント実装が親ページの書き方で挙動を変えていた。内部チャネルは
13721
+ * `applyChangeToWebComponent` が state element を直接引く形へ分離した。
13722
+ */
11591
13723
  class OuterStateProxyHandler {
11592
- _innerStateElement;
11593
- constructor(webComponent, stateName) {
11594
- this._innerStateElement = getStateElementByWebComponent(webComponent, stateName) ?? raiseError('State element not found for web component.');
11595
- }
11596
- get(target, prop, receiver) {
11597
- if (typeof prop === 'string') {
11598
- const innerPathInfo = getPathInfo(prop);
11599
- const innerAbsPathInfo = getAbsolutePathInfo(this._innerStateElement, innerPathInfo);
11600
- const absStateAddress = createAbsoluteStateAddress(innerAbsPathInfo, null);
11601
- return getLastValueByAbsoluteStateAddress(absStateAddress);
11602
- }
11603
- else {
11604
- return Reflect.get(target, prop, receiver);
11605
- }
11606
- }
11607
- set(target, prop, value, receiver) {
11608
- if (typeof prop === 'string') {
11609
- const innerPathInfo = getPathInfo(prop);
11610
- const innerAbsPathInfo = getAbsolutePathInfo(this._innerStateElement, innerPathInfo);
11611
- this._innerStateElement.createState("readonly", (state) => {
11612
- state.$postUpdate(innerAbsPathInfo.pathInfo.path);
11613
- });
11614
- return true;
11615
- }
11616
- else {
11617
- return Reflect.set(target, prop, value, receiver);
11618
- }
11619
- }
11620
- }
11621
- function createOuterState(webComponent, stateName) {
11622
- const handler = new OuterStateProxyHandler(webComponent, stateName);
11623
- return new Proxy({}, handler);
11624
- }
11625
-
11626
- class PlainOuterStateProxyHandler {
11627
13724
  _innerStateElement;
11628
13725
  constructor(webComponent, stateName) {
11629
13726
  this._innerStateElement = getStateElementByWebComponent(webComponent, stateName) ?? raiseError('State element not found for web component.');
@@ -11652,36 +13749,44 @@ class PlainOuterStateProxyHandler {
11652
13749
  }
11653
13750
  }
11654
13751
  }
11655
- function createPlainOuterState(webComponent, stateName) {
11656
- const handler = new PlainOuterStateProxyHandler(webComponent, stateName);
13752
+ function createOuterState(webComponent, stateName) {
13753
+ const handler = new OuterStateProxyHandler(webComponent, stateName);
11657
13754
  return new Proxy({}, handler);
11658
13755
  }
11659
13756
 
11660
13757
  const getOuter = (outerState) => () => outerState;
11661
13758
  function bindWebComponent(innerStateElement, component, stateProp, state) {
11662
13759
  setStateElementByWebComponent(component, stateProp, innerStateElement);
11663
- if (component.hasAttribute(config.bindAttributeName)) {
11664
- const bindings = (getBindingsByNode(component) ?? []).filter(binding => binding.propSegments[0] === stateProp);
13760
+ // 分岐は「data-wcs 属性の有無」ではなく「<stateProp>.* バインドが 1 件以上あるか」で決める。
13761
+ // 属性はあってもマッピング対象が 0 件(例: data-wcs="class.on: flag" だけ)の場合、
13762
+ // buildPrimaryMappingRule は primaryMappingRule を 1 件も作らないまま return するため、
13763
+ // outerState の lastValue / $postUpdate 意味論だけが残る。その状態では
13764
+ // component[stateProp] の read が常に undefined・write が完全な no-op になる
13765
+ // (docs/architecture-hardening/15-state-component-mechanism-consistency.md §1.2)。
13766
+ const bindings = component.hasAttribute(config.bindAttributeName)
13767
+ ? (getBindingsByNode(component) ?? []).filter(binding => binding.propSegments[0] === stateProp)
13768
+ : [];
13769
+ // 分岐が決めるのは「子の state の中身」だけ。mapped なら親 state へ解決する
13770
+ // innerState proxy、plain なら melt 済みのローカル state。
13771
+ if (bindings.length > 0) {
11665
13772
  buildPrimaryMappingRule(component, stateProp, bindings);
11666
- const outerState = createOuterState(component, stateProp);
11667
- const innerState = createInnerState(component, stateProp);
11668
- innerStateElement.setInitialState(innerState);
11669
- Object.defineProperty(component, stateProp, {
11670
- get: getOuter(outerState),
11671
- enumerable: true,
11672
- configurable: true,
11673
- });
13773
+ // 値の正本が親スコープにあることを state 要素に記録する。越境アドレスの受け渡しと
13774
+ // リストパスの外向き伝播はこのフラグでのみ有効になる(§1.8)。
13775
+ innerStateElement.markComponentStateMapped?.();
13776
+ innerStateElement.setInitialState(createInnerState(component, stateProp));
11674
13777
  }
11675
13778
  else {
11676
13779
  innerStateElement.setInitialState(meltFrozenObject(state));
11677
- const outerState = createPlainOuterState(component, stateProp);
11678
- Object.defineProperty(component, stateProp, {
11679
- get: getOuter(outerState),
11680
- enumerable: true,
11681
- configurable: true,
11682
- });
11683
13780
  }
11684
- markWebComponentAsComplete(component, innerStateElement);
13781
+ // 外向きに露出する proxy は両者で同一。mapped でも read はライブ・write は
13782
+ // innerState 経由で親 state に届く(§1.1 / G1)。
13783
+ const outerState = createOuterState(component, stateProp);
13784
+ Object.defineProperty(component, stateProp, {
13785
+ get: getOuter(outerState),
13786
+ enumerable: true,
13787
+ configurable: true,
13788
+ });
13789
+ markWebComponentAsComplete(component, stateProp);
11685
13790
  if (WEBCOMPONENT_STATE_READY_CALLBACK_NAME in component) {
11686
13791
  const func = component[WEBCOMPONENT_STATE_READY_CALLBACK_NAME];
11687
13792
  if (typeof func === 'function') {
@@ -11695,15 +13800,6 @@ function bindWebComponent(innerStateElement, component, stateProp, state) {
11695
13800
  }
11696
13801
  }
11697
13802
 
11698
- function getAllPropertyDescriptors(obj) {
11699
- let descriptors = {};
11700
- let proto = obj;
11701
- while (proto && proto !== Object.prototype) {
11702
- Object.assign(descriptors, Object.getOwnPropertyDescriptors(proto));
11703
- proto = Object.getPrototypeOf(proto);
11704
- }
11705
- return descriptors;
11706
- }
11707
13803
  function getStateInfo(state) {
11708
13804
  const getterPaths = new Set();
11709
13805
  const setterPaths = new Set();
@@ -11739,22 +13835,27 @@ class State extends HTMLElementBase {
11739
13835
  _resolveInitialize = null;
11740
13836
  _connectedCallbackPromise;
11741
13837
  _resolveConnectedCallback = null;
13838
+ _rejectConnectedCallback = null;
11742
13839
  _loadingPromise;
11743
13840
  _resolveLoading = null;
11744
13841
  _setStatePromise = null;
11745
13842
  _resolveSetState = null;
11746
13843
  _listPaths = new Set();
13844
+ _listKeys = null;
11747
13845
  _elementPaths = new Set();
11748
13846
  _getterPaths = new Set();
11749
13847
  _setterPaths = new Set();
11750
- _loopContextStack = createLoopContextStack();
13848
+ _loopContextStack = createLoopContextStack(() => getBaseDepth(this));
11751
13849
  _dynamicDependency = new Map();
11752
13850
  _staticDependency = new Map();
11753
13851
  _pathSet = new Set();
13852
+ // `$watch` 宣言の監視対象パス。宣言が無ければ null(setByAddress のゼロコスト契約)
13853
+ _watchPaths = null;
11754
13854
  _version = 0;
11755
13855
  _rootNode = null;
11756
13856
  _boundComponent = null;
11757
13857
  _boundComponentStateProp = null;
13858
+ _hasMappedComponentState = false;
11758
13859
  _bindableEventMap = {};
11759
13860
  _commandTokenNames = new Set();
11760
13861
  _eventTokenNames = new Set();
@@ -11774,8 +13875,9 @@ class State extends HTMLElementBase {
11774
13875
  this._initializePromise = new Promise((resolve) => {
11775
13876
  this._resolveInitialize = resolve;
11776
13877
  });
11777
- this._connectedCallbackPromise = new Promise((resolve) => {
13878
+ this._connectedCallbackPromise = new Promise((resolve, reject) => {
11778
13879
  this._resolveConnectedCallback = resolve;
13880
+ this._rejectConnectedCallback = reject;
11779
13881
  });
11780
13882
  this._loadingPromise = new Promise((resolve) => {
11781
13883
  this._resolveLoading = resolve;
@@ -11825,10 +13927,24 @@ class State extends HTMLElementBase {
11825
13927
  clearStreamNamespace(this);
11826
13928
  clearStreamRegistry(this);
11827
13929
  processStreamsDeclaration(this, value);
13930
+ // $listKeys: 宣言が無ければ null のままで、setByAddress のキー突合経路には
13931
+ // 一切入らない(docs/state-list-key-design.md §7-1)。再 set で必ず置き換える。
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);
11828
13942
  // 接続中の再 set(S13)は新宣言で即再起動する。
11829
13943
  // 初回(_initialize 中)は _initialized が false なのでここでは起動されず、
11830
13944
  // connectedCallback 側の startStreams($connectedCallback 完了後)が担う。
11831
13945
  if (this._initialized && this._rootNode !== null && !inSsr()) {
13946
+ // watch は stream より先に有効化する(stream の起動時書き込みを観測できるように)
13947
+ startWatch(this);
11832
13948
  startStreams(this);
11833
13949
  // $connectedCallback 実行中の再 set(setInitialState)では、ここで新宣言が
11834
13950
  // 起動済みのため connectedCallback 末尾の startStreams を skip させる。
@@ -11935,6 +14051,19 @@ class State extends HTMLElementBase {
11935
14051
  if (!(parentNode instanceof ShadowRoot) && !this.hasAttribute("name")) {
11936
14052
  raiseError(`"bind-component" in Light DOM requires a "name" attribute to avoid namespace conflicts with the parent scope.`);
11937
14053
  }
14054
+ // bind-component はコンポーネント側の state プロパティを唯一のソースにする。
14055
+ // state / src / json / inner <script> と併記すると、この後の _initialize が
14056
+ // そちらを採用して _setStatePromise を await しないため、bindWebComponent が
14057
+ // setInitialState で渡した innerState proxy ごと捨てられ、親↔子マッピングが
14058
+ // 無言で死ぬ。併記は必ず設定ミスなので fail-fast させる
14059
+ // (docs/architecture-hardening/15-state-component-mechanism-consistency.md §2.6)。
14060
+ const conflicting = ["state", "src", "json"].filter((name) => this.hasAttribute(name));
14061
+ if (this.querySelector('script[type="module"]') !== null) {
14062
+ conflicting.push('<script type="module">');
14063
+ }
14064
+ if (conflicting.length > 0) {
14065
+ raiseError(`"bind-component" cannot be combined with ${conflicting.join(", ")}. The component's "${this.getAttribute("bind-component")}" property is the only state source.`);
14066
+ }
11938
14067
  const boundComponentStateProp = this.getAttribute("bind-component");
11939
14068
  await customElements.whenDefined(customTagName.toLowerCase());
11940
14069
  // data-wcs属性がある場合は、上位の状態によりbinding情報の設定が完了するまで待機する
@@ -11953,6 +14082,64 @@ class State extends HTMLElementBase {
11953
14082
  bindWebComponent(this, this._boundComponent, this._boundComponentStateProp, state);
11954
14083
  }
11955
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
+ }
14115
+ /**
14116
+ * mapped な `bind-component` が切断 → 再接続したときに、束ねているパスを読み直させる(§1.9)。
14117
+ *
14118
+ * リスト行の content は再利用されるので、行が作り直されると子はこの経路を通る
14119
+ * (`_initialized` が真なので `_initializeBindWebComponent` / `_initialize` は走らず、
14120
+ * 子のバインディングは張り直されない)。切断中に親で起きた変更の通知は
14121
+ * `applyChangeToWebComponent` が切断済みを理由に落としているため、ここで読み直さないと
14122
+ * 子のビューだけが古い値のまま取り残される。何が変わったかは分からないので、
14123
+ * プライマリ規則の粒度で丸ごと読み直す。
14124
+ *
14125
+ * 読み直しの前に派生規則の memo を捨てる。派生規則の購読者(親スコープに立つ
14126
+ * バインディング)は切断で teardown されており、memo が残っていると導出が二度と
14127
+ * 走らないため購読者も張り直されない = 以後この子だけがサブパスの書き込みを
14128
+ * 受け取れなくなる。捨てておけば、直後の読み直しで導出と購読者登録が走る。
14129
+ */
14130
+ _reloadMappedPathsAfterReconnect() {
14131
+ if (!this._hasMappedComponentState || this._boundComponent === null) {
14132
+ return;
14133
+ }
14134
+ // mapped = プライマリ規則が 1 件以上あることと同義(bindWebComponent の分岐)
14135
+ const innerPaths = getPrimaryInnerPaths(this._boundComponent);
14136
+ resetDerivedMappingRules(this._boundComponent);
14137
+ this.createState("readonly", (state) => {
14138
+ for (const path of innerPaths) {
14139
+ state.$postUpdate(path);
14140
+ }
14141
+ });
14142
+ }
11956
14143
  async _callStateConnectedCallback() {
11957
14144
  await this.createStateAsync("writable", async (state) => {
11958
14145
  // stateに"$connectedCallback"があるか確認し、connectedCallbackAPIを呼び出す
@@ -12013,12 +14200,22 @@ class State extends HTMLElementBase {
12013
14200
  const parentNode = this.parentNode;
12014
14201
  if (parentNode instanceof ShadowRoot &&
12015
14202
  parentNode.host.hasAttribute(DCC_DEFINITION_ATTRIBUTE)) {
14203
+ // DCC と bind-component は排他。DCC の state はテンプレートに属し、
14204
+ // インスタンスごとにロードされるので、定義時点のホストのプロパティを
14205
+ // ソースにする bind-component とは両立しない。従来はこの return で
14206
+ // 無言に無視していた(docs/architecture-hardening/15 §3.1)。
14207
+ if (this.hasAttribute("bind-component")) {
14208
+ raiseError(`"bind-component" cannot be used inside a [${DCC_DEFINITION_ATTRIBUTE}] host. DCC state comes from the template, not from a component property.`);
14209
+ }
12016
14210
  await this._initializeDCC(parentNode.host, parentNode);
12017
14211
  return;
12018
14212
  }
12019
14213
  await this._initializeBindWebComponent();
12020
14214
  await this._initialize();
12021
14215
  this._initialized = true;
14216
+ // 名前登録(_initialize の末尾)が済んだこの時点でなければ、子スコープの
14217
+ // `@name` 参照が解決できない(§1.13)
14218
+ this._initializeLightDomComponentScope();
12022
14219
  this._resolveInitialize?.();
12023
14220
  }
12024
14221
  else if (!this._dcc && getStateElementByName(this._rootNode, this._name) !== this) {
@@ -12026,6 +14223,7 @@ class State extends HTMLElementBase {
12026
14223
  // createState が rootNode 経由でこの要素を解決できるようにするために必要
12027
14224
  // ($connectedCallback の再実行と $streams の initial からの再起動が依存する、設計書 §2-3)。
12028
14225
  setStateElementByName(this._rootNode, this._name, this);
14226
+ this._reloadMappedPathsAfterReconnect();
12029
14227
  }
12030
14228
  // enable-ssr (クライアント側): SSR で $connectedCallback 済みなのでスキップ
12031
14229
  // inSsr() (サーバー側): レンダリング中なので実行する
@@ -12034,14 +14232,24 @@ class State extends HTMLElementBase {
12034
14232
  }
12035
14233
  // サーバーモード + enable-ssr: バインディング完了後に <wcs-ssr> を生成
12036
14234
  if (inSsr() && this.hasAttribute('enable-ssr')) {
12037
- await getBindingsReady(this.rootNode);
12038
- const name = this.getAttribute('name') || 'default';
12039
- const stateData = Ssr.extractStateData(this);
12040
- const ssrEl = document.createElement(config.tagNames.ssr);
12041
- ssrEl.setAttribute('name', name);
12042
- ssrEl.setAttribute('version', VERSION);
12043
- Ssr.buildContent(ssrEl, stateData);
12044
- this.parentNode?.insertBefore(ssrEl, this);
14235
+ try {
14236
+ await getBindingsReady(this.rootNode);
14237
+ const name = this.getAttribute('name') || 'default';
14238
+ const stateData = Ssr.extractStateData(this);
14239
+ const ssrEl = document.createElement(config.tagNames.ssr);
14240
+ ssrEl.setAttribute('name', name);
14241
+ ssrEl.setAttribute('version', VERSION);
14242
+ Ssr.buildContent(ssrEl, stateData);
14243
+ this.parentNode?.insertBefore(ssrEl, this);
14244
+ }
14245
+ catch (error) {
14246
+ // reject を配管しないと _connectedCallbackPromise が永久に未解決になり、
14247
+ // renderToString が mutex を握ったまま connectedCallbackPromise 待ちで
14248
+ // 無言ハングする。getBindingsReady の reject 化(設計書 §8.2)を
14249
+ // SSR の消費者(render.ts)まで届けるための対。
14250
+ this._rejectConnectedCallback?.(error);
14251
+ throw error;
14252
+ }
12045
14253
  }
12046
14254
  // $streams の eager 起動($connectedCallback 完了後、設計書 §2-3)。
12047
14255
  // inSsr() 時は起動しない(SSR 出力には initial が乗る、§7-1)。
@@ -12059,6 +14267,19 @@ class State extends HTMLElementBase {
12059
14267
  // _streamsStartedGeneration ガード: $connectedCallback 内の setInitialState
12060
14268
  // (接続中の再 set)で _state セッター側が新宣言を起動済みの場合は skip する
12061
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
+ }
12062
14283
  if (!inSsr() &&
12063
14284
  this._rootNode !== null &&
12064
14285
  connectGeneration === this._connectGeneration &&
@@ -12088,10 +14309,17 @@ class State extends HTMLElementBase {
12088
14309
  // registry は残るため再接続後の初回アクセスで同内容の proxy が再生成される)。
12089
14310
  abortAllStreams(this);
12090
14311
  clearStreamNamespace(this);
14312
+ // watch は発火対象から外すだけで registry は保持する(stream の abortAllStreams と
14313
+ // 同じ二段構え、設計書 §9)。registry まで捨てると、_state セッターが再度走らない
14314
+ // 再接続で宣言を作り直せず watch が二度と発火しない。
14315
+ deactivateWatch(this);
12091
14316
  this._rootNode = null;
12092
14317
  }
12093
14318
  }
12094
14319
  }
14320
+ get initialized() {
14321
+ return this._initialized;
14322
+ }
12095
14323
  get initializePromise() {
12096
14324
  return this._initializePromise;
12097
14325
  }
@@ -12101,6 +14329,12 @@ class State extends HTMLElementBase {
12101
14329
  get listPaths() {
12102
14330
  return this._listPaths;
12103
14331
  }
14332
+ get listKeys() {
14333
+ return this._listKeys;
14334
+ }
14335
+ get watchPaths() {
14336
+ return this._watchPaths;
14337
+ }
12104
14338
  get elementPaths() {
12105
14339
  return this._elementPaths;
12106
14340
  }
@@ -12128,9 +14362,29 @@ class State extends HTMLElementBase {
12128
14362
  }
12129
14363
  return this._rootNode;
12130
14364
  }
14365
+ /**
14366
+ * `rootNode` を保持しているか = `createState` を呼んでよいか(§1.9)。
14367
+ * disconnect で落ち、connect の冒頭で復活する。
14368
+ */
14369
+ get hasRootNode() {
14370
+ return this._rootNode !== null;
14371
+ }
12131
14372
  get boundComponentStateProp() {
12132
14373
  return this._boundComponentStateProp;
12133
14374
  }
14375
+ get boundComponent() {
14376
+ return this._boundComponent;
14377
+ }
14378
+ get hasMappedComponentState() {
14379
+ return this._hasMappedComponentState;
14380
+ }
14381
+ /**
14382
+ * この state の実体が innerState proxy であることを記録する。唯一の呼び手は
14383
+ * `bindWebComponent` の mapped 分岐(§1.8)。
14384
+ */
14385
+ markComponentStateMapped() {
14386
+ this._hasMappedComponentState = true;
14387
+ }
12134
14388
  get bindableEventMap() {
12135
14389
  return this._bindableEventMap;
12136
14390
  }
@@ -12187,8 +14441,15 @@ class State extends HTMLElementBase {
12187
14441
  }
12188
14442
  setPathInfo(path, bindingType) {
12189
14443
  if (bindingType === "for") {
14444
+ const isNewListPath = !this._listPaths.has(path);
12190
14445
  this._listPaths.add(path);
12191
14446
  this._elementPaths.add(path + '.' + WILDCARD);
14447
+ // mapped な bind-component の子が回している for は、配列の実体を親スコープが
14448
+ // 持っている。親の依存 walk / swap 判定はどちらも「その state 要素の」
14449
+ // listPaths・elementPaths を見るので、マップ先のパスにも同じ宣言を届ける(§1.8)。
14450
+ if (isNewListPath && this._hasMappedComponentState) {
14451
+ propagateListPathToOuterState(this, path);
14452
+ }
12192
14453
  }
12193
14454
  if (!this._pathSet.has(path)) {
12194
14455
  const pathInfo = getPathInfo(path);
@@ -12385,6 +14646,8 @@ const builtinFilterMeta = {
12385
14646
  mul: { description: "乗算", hasArgs: true, resultType: "number", acceptTypes: ["number"], minArgs: 1, maxArgs: 1, argTypes: ["number"] },
12386
14647
  div: { description: "除算", hasArgs: true, resultType: "number", acceptTypes: ["number"], minArgs: 1, maxArgs: 1, argTypes: ["number"] },
12387
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"] },
12388
14651
  // 数値フォーマット
12389
14652
  fix: { description: "固定小数点表記", hasArgs: true, resultType: "string", acceptTypes: ["number"], minArgs: 0, maxArgs: 1, argTypes: ["number"] },
12390
14653
  locale: { description: "ロケール形式で数値フォーマット", hasArgs: true, resultType: "string", acceptTypes: ["number"], minArgs: 0, maxArgs: 1, argTypes: ["string"] },
@@ -12398,6 +14661,8 @@ const builtinFilterMeta = {
12398
14661
  pad: { description: "パディング (length[,char])", hasArgs: true, resultType: "string", acceptTypes: ["string"], minArgs: 1, maxArgs: 2, argTypes: ["number", "string"] },
12399
14662
  rep: { description: "繰り返し (count)", hasArgs: true, resultType: "string", acceptTypes: ["string"], minArgs: 1, maxArgs: 1, argTypes: ["number"] },
12400
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"] },
12401
14666
  // 数値パース・丸め
12402
14667
  int: { description: "整数にパース", hasArgs: false, resultType: "number", acceptTypes: ["string", "number"], minArgs: 0, maxArgs: 0 },
12403
14668
  float: { description: "浮動小数点数にパース", hasArgs: false, resultType: "number", acceptTypes: ["string", "number"], minArgs: 0, maxArgs: 0 },
@@ -12405,11 +14670,15 @@ const builtinFilterMeta = {
12405
14670
  floor: { description: "切り下げ", hasArgs: true, resultType: "number", acceptTypes: ["number"], minArgs: 0, maxArgs: 1, argTypes: ["number"] },
12406
14671
  ceil: { description: "切り上げ", hasArgs: true, resultType: "number", acceptTypes: ["number"], minArgs: 0, maxArgs: 1, argTypes: ["number"] },
12407
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"] },
12408
14676
  // 日付・時刻
12409
14677
  date: { description: "ロケール形式の日付", hasArgs: false, resultType: "string", acceptTypes: "any", minArgs: 0, maxArgs: 0 },
12410
14678
  time: { description: "ロケール形式の時刻", hasArgs: false, resultType: "string", acceptTypes: "any", minArgs: 0, maxArgs: 0 },
12411
14679
  datetime: { description: "ロケール形式の日時", hasArgs: false, resultType: "string", acceptTypes: "any", minArgs: 0, maxArgs: 0 },
12412
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"] },
12413
14682
  // 真偽値・変換
12414
14683
  falsy: { description: "偽値か判定", hasArgs: false, resultType: "boolean", acceptTypes: "any", minArgs: 0, maxArgs: 0 },
12415
14684
  truthy: { description: "真値か判定", hasArgs: false, resultType: "boolean", acceptTypes: "any", minArgs: 0, maxArgs: 0 },
@@ -12465,11 +14734,14 @@ function getWcsManifest() {
12465
14734
  ],
12466
14735
  reservedStateApi: [
12467
14736
  STATE_BINDABLES_NAME,
14737
+ STATE_COMMANDS_NAME,
12468
14738
  STATE_COMMAND_TOKENS_NAME,
12469
14739
  STATE_COMMAND_NAMESPACE_NAME,
12470
14740
  STATE_EVENT_TOKENS_NAME,
12471
14741
  STATE_ON_NAME,
12472
14742
  STATE_STREAMS_NAME,
14743
+ STATE_WATCH_NAME,
14744
+ STATE_LIST_KEYS_NAME,
12473
14745
  STATE_STREAM_STATUS_NAMESPACE_NAME,
12474
14746
  STATE_STREAM_ERROR_NAMESPACE_NAME,
12475
14747
  ],