@wcstack/state 1.25.0 → 1.26.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
@@ -158,11 +158,13 @@ const STATE_DISCONNECTED_CALLBACK_NAME = "$disconnectedCallback";
158
158
  const STATE_UPDATED_CALLBACK_NAME = "$updatedCallback";
159
159
  const WEBCOMPONENT_STATE_READY_CALLBACK_NAME = "$stateReadyCallback";
160
160
  const STATE_BINDABLES_NAME = "$bindables";
161
+ const STATE_COMMANDS_NAME = "$commands";
161
162
  const STATE_COMMAND_TOKENS_NAME = "$commandTokens";
162
163
  const STATE_COMMAND_NAMESPACE_NAME = "$command";
163
164
  const STATE_EVENT_TOKENS_NAME = "$eventTokens";
164
165
  const STATE_ON_NAME = "$on";
165
166
  const STATE_STREAMS_NAME = "$streams";
167
+ const STATE_LIST_KEYS_NAME = "$listKeys";
166
168
  const STATE_STREAM_STATUS_NAMESPACE_NAME = "$streamStatus";
167
169
  const STATE_STREAM_ERROR_NAMESPACE_NAME = "$streamError";
168
170
  const DCC_DEFINITION_ATTRIBUTE = "data-wc-definition";
@@ -612,15 +614,6 @@ function getBindingsByNode(node) {
612
614
  function setBindingsByNode(node, bindings) {
613
615
  bindingsByNode.set(node, bindings);
614
616
  }
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
617
 
625
618
  const STRUCTURAL_BINDING_TYPE_SET = new Set([
626
619
  "if",
@@ -2058,6 +2051,56 @@ function calcWildcardLen(pathInfo, targetPathInfo) {
2058
2051
  return len;
2059
2052
  }
2060
2053
 
2054
+ /**
2055
+ * list/wildcardLevel.ts
2056
+ *
2057
+ * 「パス上のワイルドカード位置」→「listIndex チェーン上の段」の変換を 1 箇所に集める。
2058
+ *
2059
+ * チェーンの長さは常に `Δ + W`(W = そのパスの wildcardCount、Δ = そのスコープの
2060
+ * base 深さ)である。通常の state スコープは Δ=0 なので「位置 i = 段 i」で済んでいたが、
2061
+ * `bind-component` の子スコープがホストの `for` の内側にいる場合は Δ>0 になる
2062
+ * (docs/state-bind-component-nested-for-design.md)。
2063
+ *
2064
+ * そこで**先頭ではなく末尾を基準に数える**。`IListIndex.at()` は負値を受けるので:
2065
+ *
2066
+ * at(i) → at(i - W) // listIndexes[(Δ+W) + (i-W)] = listIndexes[Δ+i]
2067
+ *
2068
+ * Δ=0 のときは両者が同じ要素を指すため、この書き換えは既存スコープに対して
2069
+ * 意味論を変えない。Δ の値を呼び出し側へ配管する必要も無い。
2070
+ */
2071
+ /**
2072
+ * ワイルドカード位置 `wildcardPos`(先頭から 0 始まり)に対応する listIndex を返す。
2073
+ * `wildcardCount` は `wildcardPos` が属するパスのワイルドカード総数。
2074
+ *
2075
+ * 範囲外(`wildcardPos >= wildcardCount`)は null。Δ=0 では `at(pos)` が
2076
+ * チェーン長を超えて null を返していたのと同じ結果になる。**このガードは必須**で、
2077
+ * 落とすと「1 段ループの中で `$2` を読む」が黙って `$1` を返す
2078
+ * (末尾起点では `at(1-1)=at(0)` に化けるため)。
2079
+ */
2080
+ function listIndexAtWildcard(listIndex, wildcardPos, wildcardCount) {
2081
+ if (wildcardPos < 0 || wildcardPos >= wildcardCount) {
2082
+ return null;
2083
+ }
2084
+ return listIndex.at(wildcardPos - wildcardCount);
2085
+ }
2086
+ /**
2087
+ * ユーザーランドへ渡すインデックス列。チェーンの先頭 Δ 段(base)を落とし、
2088
+ * **そのスコープ自身のループ分だけ**にする。
2089
+ *
2090
+ * コンポーネントの作者は、自分がリストの中に置かれるかどうかを知らずに書く。
2091
+ * `$1` や `onClick(event, index)` の意味が設置場所で変わってはいけないので、
2092
+ * Δ は境界の内側に閉じ込める。`$resolve(path, indexes)` は台帳の配列位置で
2093
+ * 引くため、ここで返した列がそのまま往復で使える。
2094
+ */
2095
+ function getScopedIndexes(listIndex, wildcardCount) {
2096
+ // indexes は型上は必須だが、防御的フォールバックを既存挙動として持っている
2097
+ const indexes = listIndex.indexes ?? [];
2098
+ if (indexes.length === wildcardCount) {
2099
+ return indexes;
2100
+ }
2101
+ return indexes.slice(indexes.length - wildcardCount);
2102
+ }
2103
+
2061
2104
  const listIndexByBindingInfoByLoopContext = new WeakMap();
2062
2105
  function getListIndexByBindingInfo(bindingInfo) {
2063
2106
  const loopContext = getLoopContextByNode(bindingInfo.node);
@@ -2079,7 +2122,7 @@ function getListIndexByBindingInfo(bindingInfo) {
2079
2122
  try {
2080
2123
  const wildcardLen = calcWildcardLen(loopContext.pathInfo, bindingInfo.statePathInfo);
2081
2124
  if (wildcardLen > 0) {
2082
- listIndex = loopContext.listIndex.at(wildcardLen - 1);
2125
+ listIndex = listIndexAtWildcard(loopContext.listIndex, wildcardLen - 1, loopContext.pathInfo.wildcardCount);
2083
2126
  }
2084
2127
  return listIndex;
2085
2128
  }
@@ -2702,7 +2745,8 @@ function attachEventTokenHandler(binding) {
2702
2745
  const loopContext = getLoopContextByNode(element);
2703
2746
  stateElement.createStateAsync("writable", async (state) => {
2704
2747
  const results = state[setLoopContextSymbol](loopContext, () => {
2705
- const indexes = loopContext?.listIndex.indexes ?? [];
2748
+ const indexes = loopContext !== null
2749
+ ? getScopedIndexes(loopContext.listIndex, loopContext.pathInfo.wildcardCount) : [];
2706
2750
  const token = getOrCreateEventToken(stateElement, tokenName);
2707
2751
  return token.emit(state, event, ...indexes);
2708
2752
  });
@@ -2787,7 +2831,8 @@ const stateEventHandlerFunction = (stateName, handlerName, modifiers, statePathI
2787
2831
  const isCommand = isCommandTokenPath(handlerName);
2788
2832
  stateElement.createStateAsync("writable", async (state) => {
2789
2833
  const results = state[setLoopContextSymbol](loopContext, () => {
2790
- const indexes = loopContext?.listIndex.indexes ?? [];
2834
+ const indexes = loopContext !== null
2835
+ ? getScopedIndexes(loopContext.listIndex, loopContext.pathInfo.wildcardCount) : [];
2791
2836
  if (isCommand) {
2792
2837
  // command token を解決して emit。引数はハンドラ呼び出しと同じく (event, ...listIndexes) を透過する。
2793
2838
  const token = state[getByAddressSymbol](createStateAddress(statePathInfo, null));
@@ -3356,6 +3401,358 @@ function detachTwowayEventHandler(binding) {
3356
3401
  }
3357
3402
  }
3358
3403
 
3404
+ /**
3405
+ * webComponent/baseListIndex.ts
3406
+ *
3407
+ * mapped な `bind-component` の子スコープが「親スコープのどの行の内側にいるか」。
3408
+ *
3409
+ * ホストのコンポーネント要素が親スコープの `for` の中に置かれている場合、
3410
+ * 子スコープは実際には**ネストしたループの内側**にいる。その深さ Δ を表すのが
3411
+ * base listIndex で、子が作る listIndex はすべてこれを親に持つ。
3412
+ * 結果として `groups[i].children` の listIndex 台帳は arity Δ+1 になり、
3413
+ * これは親が `groups.*.children.*` に対して要求するものと同一になる
3414
+ * (台帳 `listIndexesByList` は配列オブジェクト同一性の WeakMap なので、
3415
+ * 1 つの配列につき 1 組しか持てない。親子で同じ組を使うのが唯一の整合手段)。
3416
+ *
3417
+ * 詳細は docs/state-bind-component-nested-for-design.md。
3418
+ *
3419
+ * **キャッシュしてはいけない。** 行 content はプールで再利用されるため、同じ
3420
+ * コンポーネント要素が別の行に付け替わる。要素をキーにした memo は §1.9 で
3421
+ * 踏んだ罠そのもので、再接続後に古い行を指し続ける。
3422
+ * 通常の state(`hasMappedComponentState` が偽)は最初の 1 行で抜けるので、
3423
+ * ホットパスに walk は載らない。
3424
+ */
3425
+ function getBaseListIndex(stateElement) {
3426
+ if (stateElement == null || stateElement.hasMappedComponentState !== true) {
3427
+ return null;
3428
+ }
3429
+ const component = stateElement.boundComponent;
3430
+ if (component == null) {
3431
+ return null;
3432
+ }
3433
+ return getLoopContextByNode(component)?.listIndex ?? null;
3434
+ }
3435
+ /** base の段数 Δ。base が無ければ 0。 */
3436
+ function getBaseDepth(stateElement) {
3437
+ return getBaseListIndex(stateElement)?.length ?? 0;
3438
+ }
3439
+ /**
3440
+ * リストの行を生成するときの親 listIndex。
3441
+ *
3442
+ * コンテナのアドレスがワイルドカードを持つ(=囲むループがある)ならその listIndex、
3443
+ * 持たない(=そのスコープのトップレベルのリスト)なら base。後者を null のままに
3444
+ * すると、子スコープのリストだけ arity 1 で作られて親の台帳と食い違う。
3445
+ *
3446
+ * **リストの行を作りうる全経路で使うこと。** 既存台帳があれば `createListDiff` は
3447
+ * 再利用するので初期描画では食い違いが見えず、**行を追加したときだけ**
3448
+ * `createListIndex(parentListIndex, i)` が新しい arity で作られて混在する。
3449
+ */
3450
+ function getListParentListIndex(stateElement, containerListIndex) {
3451
+ return containerListIndex ?? getBaseListIndex(stateElement);
3452
+ }
3453
+
3454
+ const stateElementByWebComponent = new WeakMap();
3455
+ function setStateElementByWebComponent(webComponent, stateName, stateElement) {
3456
+ let stateMap = stateElementByWebComponent.get(webComponent);
3457
+ if (!stateMap) {
3458
+ stateMap = new Map();
3459
+ stateElementByWebComponent.set(webComponent, stateMap);
3460
+ }
3461
+ stateMap.set(stateName, stateElement);
3462
+ }
3463
+ function getStateElementByWebComponent(webComponent, stateName) {
3464
+ const stateMap = stateElementByWebComponent.get(webComponent);
3465
+ if (!stateMap) {
3466
+ return null;
3467
+ }
3468
+ return stateMap.get(stateName) ?? null;
3469
+ }
3470
+
3471
+ const innerMappingByElement = new WeakMap();
3472
+ const outerMappingByElement = new WeakMap();
3473
+ const primaryMappingRuleSetByElement = new WeakMap();
3474
+ const primaryBindingByMappingRule = new WeakMap();
3475
+ function createMappingRuleByBinding(innerState, binding) {
3476
+ const innerPathInfo = getPathInfo(binding.propSegments.slice(1).join(DELIMITER));
3477
+ const innerAbsPathInfo = getAbsolutePathInfo(innerState, innerPathInfo);
3478
+ const outerAbsStateAddress = getAbsoluteStateAddressByBinding(binding);
3479
+ const outerAbsPathInfo = outerAbsStateAddress.absolutePathInfo;
3480
+ return { innerAbsPathInfo, outerAbsPathInfo };
3481
+ }
3482
+ function buildPrimaryMappingRule(webComponent, stateName, bindings) {
3483
+ if (bindings.length === 0) {
3484
+ return;
3485
+ }
3486
+ const innerState = getStateElementByWebComponent(webComponent, stateName);
3487
+ if (innerState === null) {
3488
+ raiseError('State element not found for web component.');
3489
+ }
3490
+ const innerMappingRule = new Map();
3491
+ const outerMappingRule = new Map();
3492
+ for (const binding of bindings) {
3493
+ const mappingRule = createMappingRuleByBinding(innerState, binding);
3494
+ let primaryMappingRuleSet = primaryMappingRuleSetByElement.get(webComponent);
3495
+ if (typeof primaryMappingRuleSet === 'undefined') {
3496
+ primaryMappingRuleSetByElement.set(webComponent, new Set([mappingRule]));
3497
+ }
3498
+ else {
3499
+ primaryMappingRuleSet.add(mappingRule);
3500
+ }
3501
+ const innerAbsPathInfo = mappingRule.innerAbsPathInfo;
3502
+ const outerAbsPathInfo = mappingRule.outerAbsPathInfo;
3503
+ primaryBindingByMappingRule.set(mappingRule, binding);
3504
+ innerMappingRule.set(innerAbsPathInfo, outerAbsPathInfo);
3505
+ outerMappingRule.set(outerAbsPathInfo, innerAbsPathInfo);
3506
+ }
3507
+ innerMappingByElement.set(webComponent, innerMappingRule);
3508
+ outerMappingByElement.set(webComponent, outerMappingRule);
3509
+ }
3510
+ /**
3511
+ * プライマリ規則だけを残して、遅延導出された派生規則の memo を捨てる(§1.9)。
3512
+ *
3513
+ * 派生規則は導出と同時に「親スコープの購読者」を立てる。その購読者は子の切断で
3514
+ * teardown されるが、memo は要素をキーに残り続けるため、再接続後は**導出が二度と
3515
+ * 走らず購読者も張り直されない** — 親がサブパスへ書いても子に届かなくなる。
3516
+ * リスト行の content 再利用で実際に踏む(行を差し替えると、その行の子だけが
3517
+ * 以後の行フィールド書き込みを受け取れない)。
3518
+ *
3519
+ * `buildPrimaryMappingRule` は再バインド時に同じことをしている(台帳を作り直す)。
3520
+ * 再接続では bindWebComponent が走らないので、ここで同じ状態に戻す。
3521
+ */
3522
+ function resetDerivedMappingRules(webComponent) {
3523
+ const primaryMappingRuleSet = primaryMappingRuleSetByElement.get(webComponent);
3524
+ if (typeof primaryMappingRuleSet === 'undefined') {
3525
+ return;
3526
+ }
3527
+ const innerMappingRule = new Map();
3528
+ const outerMappingRule = new Map();
3529
+ for (const rule of primaryMappingRuleSet) {
3530
+ innerMappingRule.set(rule.innerAbsPathInfo, rule.outerAbsPathInfo);
3531
+ outerMappingRule.set(rule.outerAbsPathInfo, rule.innerAbsPathInfo);
3532
+ }
3533
+ innerMappingByElement.set(webComponent, innerMappingRule);
3534
+ outerMappingByElement.set(webComponent, outerMappingRule);
3535
+ }
3536
+ /**
3537
+ * このコンポーネントに張られたプライマリ規則の**内側パス**を列挙する。
3538
+ *
3539
+ * 切断 → 再接続を跨いだ子(行 content の再利用で起きる)は、切断中に親で起きた変更の
3540
+ * 通知を受け取れていない。再接続時に「束ねているパスを読み直せ」と撃つための入力で、
3541
+ * 何が変わったかは分からないのでプライマリ規則の粒度で丸ごと読み直す(§1.9)。
3542
+ */
3543
+ function getPrimaryInnerPaths(webComponent) {
3544
+ const primaryMappingRuleSet = primaryMappingRuleSetByElement.get(webComponent);
3545
+ if (typeof primaryMappingRuleSet === 'undefined') {
3546
+ return [];
3547
+ }
3548
+ const paths = [];
3549
+ for (const rule of primaryMappingRuleSet) {
3550
+ paths.push(rule.innerAbsPathInfo.pathInfo.path);
3551
+ }
3552
+ return paths;
3553
+ }
3554
+ /**
3555
+ * 内側のパスを外側のパスへ翻訳する。規則が無ければプライマリ規則から導出する。
3556
+ *
3557
+ * `registerSubscriber` は導出に**副作用を持たせるか**の切り替え。既定(子の read /
3558
+ * write からの呼び出し)では導出した規則を台帳に memo し、対応するバインディングを
3559
+ * 親スコープの購読者として登録する。`false` を渡すと**参照専用**になり、台帳にも
3560
+ * 購読者にも触れない。
3561
+ *
3562
+ * 参照専用が要るのは、バインディング登録の最中(`BindingSession.registerAddress` →
3563
+ * `setPathInfo` / 行の相乗り登録)に翻訳だけしたい場合。ここで購読者登録まで走ると
3564
+ * `session.initialize` がセッション操作の内側から再入する。
3565
+ *
3566
+ * 参照専用の結果を台帳に memo しないのは、後から来た**本物の read が memo に当たって
3567
+ * 購読者登録を永久に飛ばしてしまう**ため。導出のやり直しは初回だけで、以降は本物の
3568
+ * read が張った memo に当たる(行 2 本目以降の登録は先頭行の read が埋めた台帳を引く)。
3569
+ */
3570
+ function getOuterAbsolutePathInfo(webComponent, innerAbsPathInfo, registerSubscriber = true) {
3571
+ let innerMapping = innerMappingByElement.get(webComponent);
3572
+ if (typeof innerMapping === 'undefined') {
3573
+ innerMapping = new Map();
3574
+ innerMappingByElement.set(webComponent, innerMapping);
3575
+ }
3576
+ if (innerMapping.has(innerAbsPathInfo)) {
3577
+ return innerMapping.get(innerAbsPathInfo);
3578
+ }
3579
+ let outerMapping = outerMappingByElement.get(webComponent);
3580
+ if (typeof outerMapping === 'undefined') {
3581
+ outerMapping = new Map();
3582
+ outerMappingByElement.set(webComponent, outerMapping);
3583
+ }
3584
+ // 内側からのアクセスの場合、ルールがなければプライマリルールから新たにルールとバインディングを生成する
3585
+ const primaryMappingRuleSet = primaryMappingRuleSetByElement.get(webComponent);
3586
+ if (typeof primaryMappingRuleSet === 'undefined') {
3587
+ // マッピングルールが存在しない場合はnullを返し、ローカル状態へのフォールバックを許可する
3588
+ return null;
3589
+ }
3590
+ let primaryMappingRule = null;
3591
+ for (const currentPrimaryMappingRule of primaryMappingRuleSet) {
3592
+ // innerPathInfoがprimaryMappingRuleのinnerPathInfoを包含しているか
3593
+ if (!innerAbsPathInfo.pathInfo.cumulativePathInfoSet.has(currentPrimaryMappingRule.innerAbsPathInfo.pathInfo)) {
3594
+ continue;
3595
+ }
3596
+ if (currentPrimaryMappingRule.innerAbsPathInfo.pathInfo.segments.length === innerAbsPathInfo.pathInfo.segments.length) {
3597
+ raiseError('Duplicate mapping rule for web component.');
3598
+ }
3599
+ primaryMappingRule = currentPrimaryMappingRule;
3600
+ break;
3601
+ }
3602
+ if (primaryMappingRule === null) {
3603
+ // マッピングルールに一致しない場合はnullを返し、ローカル状態へのフォールバックを許可する
3604
+ return null;
3605
+ }
3606
+ // マッチした残りのパスをouterPathInfoに付与して新たなルールを生成
3607
+ const primaryBinding = primaryBindingByMappingRule.get(primaryMappingRule);
3608
+ /* c8 ignore start */
3609
+ if (typeof primaryBinding === 'undefined') {
3610
+ raiseError('Binding not found for primary mapping rule on web component.');
3611
+ }
3612
+ /* c8 ignore stop */
3613
+ const outerRemainingSegments = innerAbsPathInfo.pathInfo.segments.slice(primaryMappingRule.innerAbsPathInfo.pathInfo.segments.length);
3614
+ const outerSegments = primaryMappingRule.outerAbsPathInfo.pathInfo.segments.concat(outerRemainingSegments);
3615
+ const outerPathInfo = getPathInfo(outerSegments.join(DELIMITER));
3616
+ const rootNode = webComponent.getRootNode();
3617
+ const outerStateElement = getStateElementByName(rootNode, primaryBinding.stateName);
3618
+ if (outerStateElement === null) {
3619
+ raiseError(`State element with name "${primaryBinding.stateName}" not found for web component.`);
3620
+ }
3621
+ const outerAbsPathInfo = getAbsolutePathInfo(outerStateElement, outerPathInfo);
3622
+ if (!registerSubscriber) {
3623
+ // 参照専用: 台帳にも購読者にも触れず、翻訳結果だけ返す
3624
+ return outerAbsPathInfo;
3625
+ }
3626
+ innerMapping.set(innerAbsPathInfo, outerAbsPathInfo);
3627
+ outerMapping.set(outerAbsPathInfo, innerAbsPathInfo);
3628
+ // ルールに対応するバインディングを生成し、親スコープの購読者として登録する。
3629
+ //
3630
+ // 子が読んだサブパス(inner "user.name" = outer "person.name")は、子が
3631
+ // そのパスに関心を宣言したということ。親がそこへ書いたときに子へ再読込通知が
3632
+ // 届くよう、プライマリと同じ形のバインディングを立てて絶対アドレス台帳に載せる。
3633
+ //
3634
+ // propSegments は stateProp(プライマリの先頭セグメント)を保つ必要がある。
3635
+ // 適用側は先頭セグメントで束ね先の state 要素を引く(apply/applyChangeToWebComponent.ts)
3636
+ // ため、inner パスだけにすると通知先を解決できない。
3637
+ //
3638
+ // 登録はプライマリを所有する BindingSession 経由で行う。台帳登録・teardown・
3639
+ // ノード削除時の破棄(MutationObserver 配送)が既存のライフサイクルにそのまま乗り、
3640
+ // 絶対アドレス台帳のエントリが component を強参照したまま残るのを防ぐ。
3641
+ // node 台帳(addBindingByNode)へは積まない — stateProp を保った結果、
3642
+ // 再バインド時に buildPrimaryMappingRule のプライマリ抽出フィルタへ混入するため。
3643
+ const propSegments = [primaryBinding.propSegments[0], ...innerAbsPathInfo.pathInfo.segments];
3644
+ const newBinding = {
3645
+ ...primaryBinding,
3646
+ propName: propSegments.join(DELIMITER),
3647
+ propSegments,
3648
+ statePathName: outerAbsPathInfo.pathInfo.path,
3649
+ statePathInfo: outerAbsPathInfo.pathInfo,
3650
+ };
3651
+ // 登録できないケースは登録だけ諦める。ここは翻訳が本務なので read を落とさない
3652
+ // = この機構が入る前と同じ挙動に留める(debug 時のみ観測可能にする)。2 通りある。
3653
+ //
3654
+ // (a) セッションが引けない: 内部的な想定外(プライマリは親スコープの収集で必ず
3655
+ // session.initialize を通っている)。
3656
+ // (b) 導出した outer パスがワイルドカードを含むのに listIndex が決まらない:
3657
+ // 子が配列マッピングの上で for を回している場合(規則 state.items: rows に対し
3658
+ // 子の行が items.*.name を読む → outer は rows.*.name)。派生バインディングの
3659
+ // node は親スコープにあるコンポーネント要素で、ループは子の Shadow 内なので
3660
+ // コンポーネントからは行を特定できない = この 1 本では行を表現できない。
3661
+ // ここで登録を試みると getAbsoluteStateAddressByBinding が raiseError する。
3662
+ // この形の親→子配送は派生バインディングではなく、子の行バインディング自身を
3663
+ // 親のパターン台帳((absolutePathInfo, listIndex))へ相乗りさせて成立させる
3664
+ // (BindingSession.registerAddress / webComponent/outerListPath.ts、§1.8)。
3665
+ const skipRegistration = (reason) => {
3666
+ if (config.debug) {
3667
+ console.warn(`parent→child notification for "${outerAbsPathInfo.pathInfo.path}" is not registered: ${reason}.`, { webComponent, primaryBinding });
3668
+ }
3669
+ };
3670
+ const session = getBindingSession(primaryBinding);
3671
+ if (session === null) {
3672
+ skipRegistration('no binding session for the primary mapping rule');
3673
+ return outerAbsPathInfo;
3674
+ }
3675
+ if (outerAbsPathInfo.pathInfo.wildcardCount > 0 && getListIndexByBindingInfo(newBinding) === null) {
3676
+ skipRegistration('the derived outer path is a wildcard path but no list index resolves from the component');
3677
+ return outerAbsPathInfo;
3678
+ }
3679
+ // 戻り値(初期 apply 対象)は使わない。この導出は子の read の最中に起きるので、
3680
+ // 子は既に最新値を読んでおり、ここでの再通知は冗長かつ再入になる。
3681
+ session.initialize([newBinding], { registerAddress: true });
3682
+ return outerAbsPathInfo;
3683
+ }
3684
+
3685
+ /**
3686
+ * webComponent/outerListPath.ts
3687
+ *
3688
+ * mapped な `bind-component` の子スコープが宣言した「リスト」を、値の正本を持つ
3689
+ * 親スコープ側へ伝えるための翻訳ヘルパ
3690
+ * (docs/architecture-hardening/15-state-component-mechanism-consistency.md §1.8)。
3691
+ *
3692
+ * 子の `for: items` が登録するのは子の state 要素の listPaths / elementPaths だけで、
3693
+ * 配列の実体を持つ親 state 要素は `rows` がリストであることを知らないままだった。
3694
+ * 親が `rows` を書いたときの依存 walk は `rows → rows.*` の静的子展開を
3695
+ * listPaths で判定するため、未登録だと行ごとの listIndex に展開されず
3696
+ * 「listIndex null のワイルドカードアドレス」1 本に潰れる(誰にも届かない)。
3697
+ * `rows.*` が elementPaths に無いと、行そのものへの代入(swap イディオム)も
3698
+ * listIndex 台帳の付け替えを伴わない素の代入に落ちる。
3699
+ */
3700
+ /**
3701
+ * 子スコープの `for:` パスに対応する親スコープのパスへ「これはリストだ」を伝える。
3702
+ * マッピング規則が無い(plain なコンポーネント / ローカル state のリスト)場合は何もしない。
3703
+ *
3704
+ * 親がさらに別コンポーネントの mapped state であれば、その親の `setPathInfo` から
3705
+ * 再びここへ入って外向きに伝播する。各段で必ず外側の state 要素へ進むので停止する。
3706
+ */
3707
+ function propagateListPathToOuterState(innerStateElement, innerPath) {
3708
+ const outerAbsPathInfo = resolveOuterAbsolutePathInfo(innerStateElement, getPathInfo(innerPath));
3709
+ if (outerAbsPathInfo === null || outerAbsPathInfo.stateElement === innerStateElement) {
3710
+ return;
3711
+ }
3712
+ outerAbsPathInfo.stateElement.setPathInfo(outerAbsPathInfo.pathInfo.path, "for");
3713
+ }
3714
+ /**
3715
+ * 子スコープのリスト行パス(`items.*.name`)に対応する親スコープの絶対パス情報を返す。
3716
+ *
3717
+ * 呼び出し側(`BindingSession.registerAddress`)は、行バインディングを**この外側パスと
3718
+ * 子スコープの listIndex の組**で親のパターン台帳に相乗りさせる。したがって成立条件は
3719
+ * 「子の listIndex が外側パスの段数をちょうど満たすこと」=
3720
+ * `Δ + innerW === outerW`(Δ = base 深さ)。
3721
+ *
3722
+ * - コンポーネントが親の `for` の外(Δ=0): `outerW === innerW`(§1.8)
3723
+ * - コンポーネントが親の `for` の中(Δ>0): 子の listIndex は base を親に持つので
3724
+ * チェーン長が Δ+innerW になり、そのまま外側パスの段数と一致する
3725
+ * (docs/state-bind-component-nested-for-design.md)
3726
+ */
3727
+ function getOuterRowPathInfo(innerStateElement, innerPathInfo) {
3728
+ if (innerPathInfo.wildcardCount === 0) {
3729
+ return null;
3730
+ }
3731
+ const outerAbsPathInfo = resolveOuterAbsolutePathInfo(innerStateElement, innerPathInfo);
3732
+ if (outerAbsPathInfo === null || outerAbsPathInfo.stateElement === innerStateElement) {
3733
+ return null;
3734
+ }
3735
+ const baseDepth = getBaseDepth(innerStateElement);
3736
+ if (outerAbsPathInfo.pathInfo.wildcardCount !== innerPathInfo.wildcardCount + baseDepth) {
3737
+ return null;
3738
+ }
3739
+ return outerAbsPathInfo;
3740
+ }
3741
+ function resolveOuterAbsolutePathInfo(innerStateElement, innerPathInfo) {
3742
+ if (innerStateElement.hasMappedComponentState !== true) {
3743
+ return null;
3744
+ }
3745
+ const component = innerStateElement.boundComponent;
3746
+ if (component == null) {
3747
+ return null;
3748
+ }
3749
+ const innerAbsPathInfo = getAbsolutePathInfo(innerStateElement, innerPathInfo);
3750
+ // 参照専用で引く。ここはバインディング登録の最中(registerAddress → setPathInfo /
3751
+ // 行の相乗り登録)から呼ばれるので、翻訳のついでに購読者登録まで走らせると
3752
+ // `session.initialize` がセッション操作の内側から再入する。
3753
+ return getOuterAbsolutePathInfo(component, innerAbsPathInfo, false);
3754
+ }
3755
+
3359
3756
  // framework 自身が detach し明示的に解体(deactivate/unmount)したノード。
3360
3757
  // BindingOwner の MutationObserver は削除サブツリー走査でこれらをスキップする。
3361
3758
  //
@@ -4154,6 +4551,7 @@ class BindingSession {
4154
4551
  address: null,
4155
4552
  patternPathInfo: null,
4156
4553
  patternListIndex: null,
4554
+ outerPatternPathInfo: null,
4157
4555
  pendingDefinitions: 0,
4158
4556
  initialPolicy: slot.policy,
4159
4557
  resolvedAuthority: slot.authority,
@@ -4269,6 +4667,7 @@ class BindingSession {
4269
4667
  address: null,
4270
4668
  patternPathInfo: null,
4271
4669
  patternListIndex: null,
4670
+ outerPatternPathInfo: null,
4272
4671
  pendingDefinitions: 0,
4273
4672
  initialPolicy: null,
4274
4673
  resolvedAuthority: null,
@@ -4477,6 +4876,15 @@ class BindingSession {
4477
4876
  addBindingByPattern(absolutePathInfo, listIndex, binding);
4478
4877
  record.patternPathInfo = absolutePathInfo;
4479
4878
  record.patternListIndex = listIndex;
4879
+ // mapped な bind-component の子スコープが回している行は、値の正本が親 state に
4880
+ // ある。親が行へ書いたときの enqueue は親の絶対パス情報で起きるので、同じ
4881
+ // listIndex(親子で共有されている)で親側のパターン台帳にも購読者として載せる。
4882
+ // これが無いと親起点の行フィールド書き込みが子に一切届かない(§1.8)。
4883
+ const outerPathInfo = getOuterRowPathInfo(stateElement, binding.statePathInfo);
4884
+ if (outerPathInfo !== null) {
4885
+ addBindingByPattern(outerPathInfo, listIndex, binding);
4886
+ record.outerPatternPathInfo = outerPathInfo;
4887
+ }
4480
4888
  }
4481
4889
  else {
4482
4890
  const address = getAbsoluteStateAddressByBinding(binding, knownRoot);
@@ -4533,6 +4941,16 @@ class BindingSession {
4533
4941
  }
4534
4942
  }
4535
4943
  else if (record.patternListIndex !== null) {
4944
+ // 親スコープへの相乗り分は独立した資源なので、子側の解除が失敗しても取り残さない
4945
+ if (record.outerPatternPathInfo !== null) {
4946
+ try {
4947
+ removeBindingByPattern(record.outerPatternPathInfo, record.patternListIndex, binding);
4948
+ }
4949
+ catch {
4950
+ // Cleanup is best-effort.
4951
+ }
4952
+ record.outerPatternPathInfo = null;
4953
+ }
4536
4954
  try {
4537
4955
  removeBindingByPattern(record.patternPathInfo, record.patternListIndex, binding);
4538
4956
  record.patternPathInfo = null;
@@ -4590,21 +5008,35 @@ function getBindingSession(binding) {
4590
5008
  return recordByBinding.get(binding)?.session ?? null;
4591
5009
  }
4592
5010
 
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);
5011
+ /**
5012
+ * `bind-component` の配線が完了した (webComponent, stateProp) の台帳。
5013
+ *
5014
+ * 完了前は state プロパティがまだ素のオブジェクトなので、親からの適用は
5015
+ * `applyChangeToProperty` がそこへ値を積み、`bindWebComponent` が melt して取り込む。
5016
+ * 完了後は公開プロパティが outerState proxy に差し替わっているため、親からの適用は
5017
+ * 値を運ばない内部通知チャネル(`applyChangeToWebComponent`)へ切り替わる。
5018
+ * その切り替え判定がこの台帳。
5019
+ *
5020
+ * キーは「state プロパティ名」であって state 要素ではない。完了はプロパティ単位の
5021
+ * 事実(`defineProperty(component, stateProp, ...)` が済んだか)であり、
5022
+ * 1 つの要素に複数の state プロパティを束ねられる以上、粒度もプロパティ単位が正しい。
5023
+ * 以前は内側の `IStateElement` をキーにしていたが、照会側(apply/applyChange.ts)が
5024
+ * 手にしているのは *親スコープ* の `IStateElement` であり、どちらも同じ型なので
5025
+ * TypeScript が取り違えを検出できず、判定が恒久的に false になっていた
5026
+ * (=親 state 起点の変更が子コンポーネントへ届かない。
5027
+ * docs/architecture-hardening/15-state-component-mechanism-consistency.md §1.7)。
5028
+ */
5029
+ const completedStatePropsByWebComponent = new WeakMap();
5030
+ function markWebComponentAsComplete(webComponent, stateProp) {
5031
+ let completedStateProps = completedStatePropsByWebComponent.get(webComponent);
5032
+ if (!completedStateProps) {
5033
+ completedStateProps = new Set();
5034
+ completedStatePropsByWebComponent.set(webComponent, completedStateProps);
4599
5035
  }
4600
- completeByStateElement.set(stateElement, true);
5036
+ completedStateProps.add(stateProp);
4601
5037
  }
4602
- function isWebComponentComplete(webComponent, stateElement) {
4603
- const completeByStateElement = completeByStateElementByWebComponent.get(webComponent);
4604
- if (!completeByStateElement) {
4605
- return false;
4606
- }
4607
- return completeByStateElement.get(stateElement) === true;
5038
+ function isWebComponentComplete(webComponent, stateProp) {
5039
+ return completedStatePropsByWebComponent.get(webComponent)?.has(stateProp) === true;
4608
5040
  }
4609
5041
 
4610
5042
  function applyChangeToAttribute(binding, _context, newValue) {
@@ -5783,7 +6215,8 @@ function applyChangeToFor(bindingInfo, context, newValue) {
5783
6215
  const listIndex = getListIndexByBindingInfo(bindingInfo);
5784
6216
  const absAddress = getAbsoluteStateAddressByBinding(bindingInfo);
5785
6217
  const lastValue = getLastListValueByAbsoluteStateAddress(absAddress);
5786
- const diff = createListDiff(listIndex, lastValue, newValue);
6218
+ // 子スコープのトップレベルのリストは base を親に持つ(webComponent/baseListIndex.ts)
6219
+ const diff = createListDiff(getListParentListIndex(context.stateElement, listIndex), lastValue, newValue);
5787
6220
  context.newListValueByAbsAddress.set(absAddress, Array.isArray(newValue) ? newValue : []);
5788
6221
  const fullDelete = Array.isArray(lastValue)
5789
6222
  && lastValue.length === diff.deleteIndexSet.size
@@ -5916,6 +6349,10 @@ function applyChangeToFor(bindingInfo, context, newValue) {
5916
6349
  if (content === null) {
5917
6350
  raiseError(`Content not found for ListIndex: ${index.index} at path "${listPathInfo.path}"`);
5918
6351
  }
6352
+ // 祖先の unmount(if の非表示など)で解体された行は、ここで物理的に
6353
+ // 戻されるだけでは binding が dispose 済みのまま復活しない。位置合わせの
6354
+ // 前に判定しておき(mountAfter が mounted を立てる)、戻した後に再活性化する。
6355
+ const unmountedByAncestor = !content.mounted;
5919
6356
  // Stable contents are already in correct relative order — but only
5920
6357
  // trust that after physical verification (see isPhysicallyAfter).
5921
6358
  // Contents out of order (and everything unverifiable) settle via the
@@ -5925,6 +6362,16 @@ function applyChangeToFor(bindingInfo, context, newValue) {
5925
6362
  if (!stable && lastNode.nextSibling !== content.firstNode) {
5926
6363
  content.mountAfter(lastNode);
5927
6364
  }
6365
+ if (unmountedByAncestor) {
6366
+ // 再活性化しないと、行の同一性が保たれる更新が以後すべて無視される
6367
+ // (docs/state-deactivated-content-stale-update.md)。activate は
6368
+ // disposed record の再構築を含むので、プール再利用と同じ経路で戻る。
6369
+ const revivedContent = content;
6370
+ const stateAddress = createStateAddress(elementPathInfo, index);
6371
+ loopContextStack.createLoopContext(stateAddress, (loopContext) => {
6372
+ activateContent(revivedContent, loopContext, context);
6373
+ });
6374
+ }
5928
6375
  }
5929
6376
  lastNode = content.lastNode || lastNode;
5930
6377
  if (typeof contentMap === 'undefined') {
@@ -6304,18 +6751,58 @@ function applyChangeToText(binding, _context, newValue) {
6304
6751
  }
6305
6752
  }
6306
6753
 
6307
- function applyChangeToWebComponent(binding, _context, newValue) {
6754
+ /**
6755
+ * 親 state → `bind-component` 済みコンポーネントの再読込通知(内部チャネル)。
6756
+ *
6757
+ * 値そのものは運ばない。バインドされたパスの正本は親 state 側にあり、子は
6758
+ * innerState proxy のマッピング経由で親を読みに行くため、必要なのは
6759
+ * 「そのパスを読み直せ」という通知だけ。
6760
+ *
6761
+ * 以前は `element[stateProp][path] = value` と、コンポーネントの公開プロパティを
6762
+ * 経由してこの通知を送っていた。受け側の proxy が値を捨てて `$postUpdate` を呼ぶ
6763
+ * 作りだったのはそのためだが、同じ proxy が `this.state` として作者にも見えていたので、
6764
+ * 公開 API 側の書き込みまで no-op になっていた。通知はここで state element を直接
6765
+ * 引く形に分離し、公開 proxy は素通し意味論に統一した
6766
+ * (docs/architecture-hardening/15-state-component-mechanism-consistency.md §1.1 / G1)。
6767
+ *
6768
+ * この関数が選ばれるのは `isWebComponentComplete` が真のときだけなので
6769
+ * (apply/applyChange.ts)、`bindWebComponent` は完了済み = state element は登録済み。
6770
+ * ただし**登録済みと使用可能は別**で、切断済みの state element が台帳に残っている
6771
+ * 窓がある(§1.9)。下の使用可能判定を参照。
6772
+ */
6773
+ function applyChangeToWebComponent(binding, _context, _newValue) {
6308
6774
  const element = binding.node;
6309
6775
  const propSegments = binding.propSegments;
6310
6776
  if (propSegments.length <= 1) {
6311
- raiseError(`Invalid propSegments for web component binding: ${propSegments.join(".")}`);
6777
+ raiseError(`Invalid propSegments for web component binding: ${propSegments.join(DELIMITER)}`);
6312
6778
  }
6313
6779
  const [firstSegment, ...restSegments] = propSegments;
6314
- const subObject = element[firstSegment];
6315
- if (typeof subObject === "undefined") {
6316
- raiseError(`Property "${firstSegment}" not found on web component.`);
6780
+ const innerStateElement = getStateElementByWebComponent(element, firstSegment);
6781
+ if (innerStateElement === null) {
6782
+ raiseError(`State element not bound to "${firstSegment}" on web component.`);
6783
+ }
6784
+ // 切断済みの state element には送らない。
6785
+ //
6786
+ // リスト行にコンポーネントがあるとき、行の再生成では **DOM に戻る前に** apply が走る。
6787
+ // 行の content(と中のコンポーネント要素)は再利用されるので、要素をキーにした
6788
+ // 台帳 `stateElementByWebComponent` は前回の state element を指したままで、
6789
+ // その要素は既に切断されている(`rootNode` を失っている)。そこへ `createState` すると
6790
+ // raiseError し、**updater の drain も applyChangeToFor の行ループも例外を捕まえない**ため、
6791
+ // 1 つの行が同じバッチの残り全部を道連れにする — 実測では for が空になったまま、
6792
+ // 以後どんな更新でも復帰しなくなる(§1.9)。
6793
+ //
6794
+ // ここは値を運ばない再読込通知なので、切断中の子に送る意味がそもそも無い。
6795
+ // 子が DOM に戻れば、子のバインディングが innerState 経由で親をライブ読みするため
6796
+ // 現在値はそのとき正しく入る(初期配送と同じ経路)。よって no-op で落として良い。
6797
+ if (innerStateElement.hasRootNode === false) {
6798
+ if (config.debug) {
6799
+ console.debug(`[@wcstack/state] skipped parent→child notification for a disconnected state element on <${element.tagName.toLowerCase()}>.`, { element, stateProp: firstSegment, path: restSegments.join(DELIMITER) });
6800
+ }
6801
+ return;
6317
6802
  }
6318
- subObject[restSegments.join(".")] = newValue;
6803
+ innerStateElement.createState("readonly", (state) => {
6804
+ state.$postUpdate(restSegments.join(DELIMITER));
6805
+ });
6319
6806
  }
6320
6807
 
6321
6808
  // indexName ... $1, $2, ...
@@ -6327,7 +6814,7 @@ function getIndexValueByLoopContext(loopContext, indexName) {
6327
6814
  if (typeof indexPos === "undefined") {
6328
6815
  raiseError(`Invalid index name: ${indexName}`);
6329
6816
  }
6330
- const listIndex = loopContext.listIndex.at(indexPos);
6817
+ const listIndex = listIndexAtWildcard(loopContext.listIndex, indexPos, loopContext.pathInfo.wildcardCount);
6331
6818
  if (listIndex === null) {
6332
6819
  raiseError(`Index not found at position ${indexPos} for loopContext:`);
6333
6820
  }
@@ -6417,6 +6904,23 @@ const deferredSelectBindingByBinding = new WeakMap();
6417
6904
  // 一度確認したら以後は不変(define は不可逆)なので apply 毎の getCustomElement /
6418
6905
  // registry 照会を省略できる。scoped registry を導入する場合はこの不可逆前提を再検討。
6419
6906
  const definedApplyVerifiedByBinding = new WeakMap();
6907
+ /**
6908
+ * このバインディングを「値を運ばない親→子の再読込通知」(applyChangeToWebComponent)へ
6909
+ * 回してよいか。
6910
+ *
6911
+ * 長さ 1 の propSegments を除くのが要点。`data-wcs="state: user"` のように
6912
+ * bind-component の stateProp をそのままプロパティ名に書いた形は、完了台帳のキーが
6913
+ * stateProp 名になった以上ゲートを通ってしまうが、applyChangeToWebComponent は
6914
+ * 「先頭セグメント=束ね先の state 要素、残り=子側のパス」を前提にしており
6915
+ * 残余が空だと raiseError する。updater の drain は例外を捕まえないので、
6916
+ * 誤設定タグ 1 つが同じバッチの無関係な更新まで巻き添えにしてしまう。
6917
+ * ここで弾いておけば従来どおり applyChangeToProperty に落ち、挙動は変わらない
6918
+ * (getter だけの公開プロパティへの代入が握り潰される = 無言の no-op)。
6919
+ */
6920
+ function isWebComponentCompleteForBinding(binding) {
6921
+ return binding.propSegments.length > 1
6922
+ && isWebComponentComplete(binding.replaceNode, binding.propSegments[0]);
6923
+ }
6420
6924
  function _applyChange(binding, context) {
6421
6925
  const value = getValue(context.state, binding);
6422
6926
  const filteredValue = getFilteredValue(value, binding.outFilters);
@@ -6430,7 +6934,7 @@ function _applyChange(binding, context) {
6430
6934
  return;
6431
6935
  }
6432
6936
  if (fnByBinding.has(binding)) {
6433
- if (isWebComponentComplete(binding.replaceNode, context.stateElement)) {
6937
+ if (isWebComponentCompleteForBinding(binding)) {
6434
6938
  fn = applyChangeToWebComponent;
6435
6939
  fnByBinding.set(binding, fn); // 確定したのでキャッシュ
6436
6940
  }
@@ -6448,7 +6952,7 @@ function _applyChange(binding, context) {
6448
6952
  if (typeof fn === 'undefined') {
6449
6953
  const customTag = getCustomElement(binding.replaceNode);
6450
6954
  if (customTag) {
6451
- if (isWebComponentComplete(binding.replaceNode, context.stateElement)) {
6955
+ if (isWebComponentCompleteForBinding(binding)) {
6452
6956
  fn = applyChangeToWebComponent;
6453
6957
  fnByBinding.set(binding, fn); // 確定したのでキャッシュ
6454
6958
  }
@@ -7102,7 +7606,7 @@ async function buildBindings(root) {
7102
7606
  }
7103
7607
  }
7104
7608
 
7105
- var version = "1.25.0";
7609
+ var version = "1.26.0";
7106
7610
  var pkg = {
7107
7611
  version: version};
7108
7612
 
@@ -7877,28 +8381,47 @@ function setStateElementByName(rootNode, name, element) {
7877
8381
  // 初めてルートノードに登録する場合
7878
8382
  // enable-ssr 属性があり、サーバーサイドでない場合はハイドレーション
7879
8383
  const enableSsr = !inSsr() && element.hasAttribute?.('enable-ssr');
8384
+ // instanceof ではなく constructor.name で判定するのは意図的。SSR では
8385
+ // @wcstack/server の installGlobals が happy-dom の一部だけを globalThis に載せるが、
8386
+ // そのリスト(GLOBALS_KEYS)に `Document` は入っていない。Node にも `Document` は
8387
+ // 無いので `rootNode instanceof Document` は ReferenceError になる。
8388
+ // `ShadowRoot` はリストに含まれるため他所では instanceof を使っている
8389
+ // (docs/architecture-hardening/15-state-component-mechanism-consistency.md §3.3)。
8390
+ // reject を配管しないと、バインディング初期化中の例外は unhandled rejection として
8391
+ // 漏れるだけで ready が永久に未解決のまま残り、await getBindingsReady() の先が
8392
+ // 無言でハングする(docs/state-bind-component-nested-for-design.md §8.2)。
7880
8393
  if (rootNode.constructor.name === 'HTMLDocument' || rootNode.constructor.name === 'Document') {
7881
- const ready = new Promise((resolve) => {
8394
+ const ready = new Promise((resolve, reject) => {
7882
8395
  queueMicrotask(async () => {
7883
- if (enableSsr) {
7884
- const success = await hydrateBindings(rootNode);
7885
- if (!success) {
8396
+ try {
8397
+ if (enableSsr) {
8398
+ const success = await hydrateBindings(rootNode);
8399
+ if (!success) {
8400
+ await buildBindings(rootNode);
8401
+ }
8402
+ }
8403
+ else {
7886
8404
  await buildBindings(rootNode);
7887
8405
  }
8406
+ resolve();
7888
8407
  }
7889
- else {
7890
- await buildBindings(rootNode);
8408
+ catch (error) {
8409
+ reject(error);
7891
8410
  }
7892
- resolve();
7893
8411
  });
7894
8412
  });
7895
8413
  bindingsReadyByNode.set(rootNode, ready);
7896
8414
  }
7897
8415
  else if (rootNode.constructor.name === 'ShadowRoot') {
7898
- const ready = new Promise((resolve) => {
8416
+ const ready = new Promise((resolve, reject) => {
7899
8417
  queueMicrotask(async () => {
7900
- await buildBindings(rootNode);
7901
- resolve();
8418
+ try {
8419
+ await buildBindings(rootNode);
8420
+ resolve();
8421
+ }
8422
+ catch (error) {
8423
+ reject(error);
8424
+ }
7902
8425
  });
7903
8426
  });
7904
8427
  bindingsReadyByNode.set(rootNode, ready);
@@ -8280,26 +8803,67 @@ function registerDevtoolsSource() {
8280
8803
  getOrCreateHookRegistry().register(source);
8281
8804
  }
8282
8805
 
8283
- async function loadFromInnerScript(script, name) {
8806
+ const CSP_GUIDE = "https://github.com/wcstack/wcstack/blob/main/docs/csp.md";
8807
+ /**
8808
+ * インライン `<script>` の評価失敗を、原因の分かるメッセージに変換する。
8809
+ *
8810
+ * CSP にブロックされた動的 import の rejection は
8811
+ * "Failed to fetch dynamically imported module" としか言わず、CSP には一切言及しない。
8812
+ * ブロックされた事実は securitypolicyviolation イベントでしか観測できないため、
8813
+ * その観測結果を `cspBlocked` で受け取る。
8814
+ *
8815
+ * 真ならブロック確定として対処方法まで書く。偽のときは構文エラー等と区別できないので、
8816
+ * 元のエラーを主にして CSP は参照先を添えるに留める(誤誘導を避ける)。
8817
+ */
8818
+ function describeImportFailure(name, error, cspBlocked) {
8819
+ const detail = error?.message ?? String(error);
8820
+ if (cspBlocked) {
8821
+ return `The inline <script> of state "${name}" was blocked by Content-Security-Policy. ` +
8822
+ `Inline state is evaluated through a blob: URL, so script-src must allow blob:. ` +
8823
+ `Prefer moving the state into an external file and loading it with src="./state.js", ` +
8824
+ `which requires no extra CSP directive. See ${CSP_GUIDE}`;
8825
+ }
8826
+ return `Failed to evaluate the inline <script> of state "${name}": ${detail}. ` +
8827
+ `If this page sets a Content-Security-Policy, see ${CSP_GUIDE}`;
8828
+ }
8829
+ async function loadFromInnerScript(script, name) {
8284
8830
  let scriptModule = null;
8285
8831
  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);
8832
+ // import() が失敗した理由が CSP かどうかを判別するために、評価の間だけ違反を購読する。
8833
+ let cspBlocked = false;
8834
+ const onViolation = (event) => {
8835
+ if (event.effectiveDirective.startsWith("script-src")) {
8836
+ cspBlocked = true;
8292
8837
  }
8293
- finally {
8294
- // Clean up blob URL to prevent memory leak
8295
- URL.revokeObjectURL(url);
8838
+ };
8839
+ document.addEventListener("securitypolicyviolation", onViolation);
8840
+ try {
8841
+ if (typeof URL.createObjectURL === 'function') {
8842
+ // Create a blob URL for the script and dynamically import it
8843
+ const blob = new Blob([script.text + uniq_comment], { type: "application/javascript" });
8844
+ const url = URL.createObjectURL(blob);
8845
+ try {
8846
+ scriptModule = await import(url);
8847
+ }
8848
+ finally {
8849
+ // Clean up blob URL to prevent memory leak
8850
+ URL.revokeObjectURL(url);
8851
+ }
8852
+ }
8853
+ else {
8854
+ // Fallback: Base64 encoding method (for test environment)
8855
+ // Convert script to Base64 and import via data: URL
8856
+ const b64 = btoa(String.fromCodePoint(...new TextEncoder().encode(script.text + uniq_comment)));
8857
+ scriptModule = await import(`data:application/javascript;base64,${b64}`);
8296
8858
  }
8297
8859
  }
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}`);
8860
+ catch (e) {
8861
+ // 呼び出し元(State._initialize / _initializeDCC)が raiseError
8862
+ // `[@wcstack/state]` を付けるため、ここでは prefix を重ねない。
8863
+ throw new Error(describeImportFailure(name, e, cspBlocked), { cause: e });
8864
+ }
8865
+ finally {
8866
+ document.removeEventListener("securitypolicyviolation", onViolation);
8303
8867
  }
8304
8868
  return (scriptModule && typeof scriptModule.default === 'object') ? scriptModule.default : {};
8305
8869
  }
@@ -8346,6 +8910,10 @@ function loadFromScriptJson(id) {
8346
8910
  class LoopContextStack {
8347
8911
  _loopContextStack = Array(MAX_LOOP_DEPTH).fill(undefined);
8348
8912
  _length = 0;
8913
+ _getBaseDepth;
8914
+ constructor(getBaseDepth) {
8915
+ this._getBaseDepth = getBaseDepth;
8916
+ }
8349
8917
  createLoopContext(elementStateAddress, callback) {
8350
8918
  if (elementStateAddress.listIndex === null) {
8351
8919
  raiseError(`Cannot create loop context for a state address that does not have a list index.`);
@@ -8367,13 +8935,21 @@ class LoopContextStack {
8367
8935
  }
8368
8936
  else {
8369
8937
  // 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.
8938
+ // listIndex chain supplies one index per wildcard, plus this scope's base
8939
+ // depth Δ. Top-level lists (wildcardCount 1) always satisfy this. A nested
8940
+ // list re-rendered directly (e.g. replaced via $resolve from outside the
8941
+ // loop) also satisfies it — the for binding's listIndex carries the full
8942
+ // ancestor chain. Δ is non-zero only for a mapped bind-component child
8943
+ // whose host sits inside a parent-scope `for`
8944
+ // (docs/state-bind-component-nested-for-design.md).
8945
+ // ここは行ごとに通る(applyChangeToFor は追加行ごとに createLoopContext する)。
8946
+ // Δ=0 の判定を先に置き、通れば base 深さの解決(DOM の親走査を含む)に
8947
+ // 一切触れない — 通常の state に追加コストを載せないため。
8375
8948
  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}).`);
8949
+ const baseDepth = this._getBaseDepth();
8950
+ if (loopContext.listIndex.length !== loopContext.pathInfo.wildcardCount + baseDepth) {
8951
+ 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}).`);
8952
+ }
8377
8953
  }
8378
8954
  }
8379
8955
  this._loopContextStack[this._length] = loopContext;
@@ -8397,8 +8973,8 @@ class LoopContextStack {
8397
8973
  return retValue;
8398
8974
  }
8399
8975
  }
8400
- function createLoopContextStack() {
8401
- return new LoopContextStack();
8976
+ function createLoopContextStack(getBaseDepth = () => 0) {
8977
+ return new LoopContextStack(getBaseDepth);
8402
8978
  }
8403
8979
 
8404
8980
  /**
@@ -8880,6 +9456,77 @@ function processStreamsDeclaration(stateElement, state) {
8880
9456
  pruneLastNotified(stateElement, new Set(entries.keys()));
8881
9457
  }
8882
9458
 
9459
+ /**
9460
+ * list/listKeys.ts
9461
+ *
9462
+ * `$listKeys: { <listPath>: <fieldName | (row) => key> }` 宣言マップを解析し、
9463
+ * 「リストパス → キー指定」表を構築する(docs/state-list-key-design.md §3)。
9464
+ *
9465
+ * この表が存在するリストパスへの配列代入は、setByAddress でキー突合され、
9466
+ * 一致行は旧オブジェクトを据え置いたまま変化フィールドだけが per-path 書き込みで
9467
+ * 流し込まれる(§2)。未宣言なら書き込み経路は従来と完全に同一。
9468
+ *
9469
+ * 「そのパスが実際にリストか」は宣言時には判定できない(listPaths は
9470
+ * バインディング収集時に確定する)。実行時に配列でなければ経路に入らないだけで、
9471
+ * 宣言自体はエラーにしない。
9472
+ */
9473
+ /**
9474
+ * `$listKeys` 宣言を検証して Map 化する。宣言が無ければ null(=ゼロコスト経路)。
9475
+ *
9476
+ * 検証内容(§3.1):
9477
+ * - `$listKeys` はオブジェクト
9478
+ * - パスは非空文字列 / 空セグメント・先頭末尾の `.` を禁止
9479
+ * - パス末尾が `*` であることを禁止(リストパスであって要素パスではない)
9480
+ * - キー指定は非空文字列か関数
9481
+ * - 文字列キーは `.` / `*` を含まないフラットなフィールド名
9482
+ * - `Object.prototype` 継承名を禁止(`__proto__` / `constructor` 等)
9483
+ */
9484
+ function processListKeysDeclaration(state) {
9485
+ const declared = state[STATE_LIST_KEYS_NAME];
9486
+ if (typeof declared === "undefined") {
9487
+ return null;
9488
+ }
9489
+ if (typeof declared !== "object" || declared === null) {
9490
+ raiseError(`${STATE_LIST_KEYS_NAME} must be an object mapping list paths to key specs.`);
9491
+ }
9492
+ const entries = new Map();
9493
+ for (const [path, spec] of Object.entries(declared)) {
9494
+ if (path.length === 0) {
9495
+ raiseError(`${STATE_LIST_KEYS_NAME} entry path must be a non-empty string.`);
9496
+ }
9497
+ const segments = path.split(DELIMITER);
9498
+ if (segments.some((segment) => segment.length === 0)) {
9499
+ raiseError(`${STATE_LIST_KEYS_NAME} entry "${path}" must not contain empty path segments.`);
9500
+ }
9501
+ if (segments[segments.length - 1] === WILDCARD) {
9502
+ raiseError(`${STATE_LIST_KEYS_NAME} entry "${path}" must be the list path itself, not the element path ` +
9503
+ `(drop the trailing "${DELIMITER}${WILDCARD}").`);
9504
+ }
9505
+ if (typeof spec === "function") {
9506
+ entries.set(path, spec);
9507
+ continue;
9508
+ }
9509
+ if (typeof spec !== "string") {
9510
+ raiseError(`${STATE_LIST_KEYS_NAME} entry "${path}" key spec must be a field name (string) or a function.`);
9511
+ }
9512
+ if (spec.length === 0) {
9513
+ raiseError(`${STATE_LIST_KEYS_NAME} entry "${path}" key field name must be a non-empty string.`);
9514
+ }
9515
+ if (spec.includes(DELIMITER) || spec.includes(WILDCARD)) {
9516
+ raiseError(`${STATE_LIST_KEYS_NAME} entry "${path}" key field name "${spec}" must be a flat property name ` +
9517
+ `("${DELIMITER}" / "${WILDCARD}" are not allowed).`);
9518
+ }
9519
+ // own key でなくても `in` 判定が真になる継承名は、キー抽出が prototype 由来の
9520
+ // 値(constructor など)を拾って全行同一キー扱いになるため名前の防衛線で落とす。
9521
+ if (spec in Object.prototype) {
9522
+ raiseError(`${STATE_LIST_KEYS_NAME} entry "${path}" key field name "${spec}" must not be a property name ` +
9523
+ `inherited from Object.prototype (e.g. "__proto__", "constructor").`);
9524
+ }
9525
+ entries.set(path, spec);
9526
+ }
9527
+ return entries.size > 0 ? entries : null;
9528
+ }
9529
+
8883
9530
  /**
8884
9531
  * stream/streamNamespace.ts
8885
9532
  *
@@ -9429,6 +10076,12 @@ function getterFn(name) {
9429
10076
  const stateEl = this.stateElement;
9430
10077
  if (!stateEl)
9431
10078
  return undefined;
10079
+ // state のロード前は「まだ値が無い」だけで異常ではない。行がまだ fragment 上にある間の
10080
+ // 初期スナップショット読み(BindingSession.readProducerSnapshot)は必ずここを通るので、
10081
+ // warn を出すと通常フローが騒がしくなる
10082
+ // (docs/architecture-hardening/15-state-component-mechanism-consistency.md §2.2)。
10083
+ if (stateEl.initialized !== true)
10084
+ return undefined;
9432
10085
  let value;
9433
10086
  try {
9434
10087
  stateEl.createState("readonly", (state) => {
@@ -9447,6 +10100,15 @@ function setterFn(name) {
9447
10100
  const stateEl = this.stateElement;
9448
10101
  if (!stateEl)
9449
10102
  return;
10103
+ // 初期化済みなら同期で書く。getter は同期なので、ここを常に initializePromise 経由に
10104
+ // すると `el.count = 5; el.count` が旧値を返す(§2.2)。未初期化のときだけ遅延させる
10105
+ // = 未接続の行に書かれた値が捨てられないための経路(§1.4)はそのまま残る。
10106
+ if (stateEl.initialized === true) {
10107
+ stateEl.createState("writable", (state) => {
10108
+ state[name] = value;
10109
+ });
10110
+ return;
10111
+ }
9450
10112
  stateEl.initializePromise.then(() => {
9451
10113
  stateEl.createState("writable", (state) => {
9452
10114
  state[name] = value;
@@ -9455,6 +10117,8 @@ function setterFn(name) {
9455
10117
  };
9456
10118
  }
9457
10119
  function callFn(name, isAsync) {
10120
+ // 戻り値は常に Promise。state 側のメソッドが同期でも初期化待ちが挟まりうるため、
10121
+ // 呼び出し側から見た型を揃える(wcBindable.commands が一律 `async: true` を宣言するのと対)。
9458
10122
  if (isAsync) {
9459
10123
  return function (...args) {
9460
10124
  const stateEl = this.stateElement;
@@ -9485,10 +10149,116 @@ function isInternalProperty(name) {
9485
10149
  return name.startsWith("$");
9486
10150
  }
9487
10151
 
9488
- function createWcBindable(tagName, bindables) {
10152
+ function getAllPropertyDescriptors(obj) {
10153
+ const chain = [];
10154
+ let proto = obj;
10155
+ while (proto && proto !== Object.prototype) {
10156
+ chain.push(proto);
10157
+ proto = Object.getPrototypeOf(proto);
10158
+ }
10159
+ const descriptors = {};
10160
+ for (let i = chain.length - 1; i >= 0; i--) {
10161
+ Object.assign(descriptors, Object.getOwnPropertyDescriptors(chain[i]));
10162
+ }
10163
+ return descriptors;
10164
+ }
10165
+
10166
+ /**
10167
+ * DCC の `$bindables` / `$commands` 宣言を解析・検証する。
10168
+ *
10169
+ * 検証の強度は `$commandTokens` / `$eventTokens` と揃える
10170
+ * (docs/architecture-hardening/15-state-component-mechanism-consistency.md §1.5 / §2.3 / §1.6)。
10171
+ * 従来 `$bindables` は `Array.isArray(...) ? ... : []` だけで、
10172
+ *
10173
+ * - 非配列を無言で空扱いにする
10174
+ * - 重複名をそのまま `createWcBindable` に流す
10175
+ * - `$` 始まりの名前を通す
10176
+ * - state に存在しない名前を通す
10177
+ *
10178
+ * という 4 つの穴があった。特に重複は害が大きい: `readNamedList`(protocol/wcBindableReader.ts)は
10179
+ * 重複名を見つけると `null` を返すため、`readBindableDeclaration()` が宣言全体を棄却し、
10180
+ * 双方向バインド・spread・initialSync の bindable 判定が**警告なしで**丸ごと死ぬ。
10181
+ * 自前のファクトリが自前の reader に棄却される状態なので、生成前に落とす。
10182
+ */
10183
+ function readNameList(state, declarationName) {
10184
+ const declared = state[declarationName];
10185
+ if (typeof declared === "undefined") {
10186
+ return null;
10187
+ }
10188
+ if (!Array.isArray(declared)) {
10189
+ raiseError(`${declarationName} must be an array of strings.`);
10190
+ }
10191
+ const names = [];
10192
+ const seen = new Set();
10193
+ for (const name of declared) {
10194
+ if (typeof name !== "string" || name.length === 0) {
10195
+ raiseError(`${declarationName} entries must be non-empty strings.`);
10196
+ }
10197
+ if (name.startsWith("$")) {
10198
+ raiseError(`${declarationName} entry "${name}" must not start with "$" (internal properties are not exposed on the component).`);
10199
+ }
10200
+ if (seen.has(name)) {
10201
+ raiseError(`${declarationName} entry "${name}" is duplicated.`);
10202
+ }
10203
+ seen.add(name);
10204
+ names.push(name);
10205
+ }
10206
+ return names;
10207
+ }
10208
+ /**
10209
+ * `$streams` が宣言している名前。値プロパティはインスタンス側の実体化まで state 上に
10210
+ * 現れないため、存在検査ではここも「実在する名前」として扱う(§2.3)。
10211
+ * 宣言そのものの妥当性検査は processStreamsDeclaration の責務なので、ここでは
10212
+ * キーの取り出しだけを行い、形が違えば黙って空集合を返す。
10213
+ */
10214
+ function getStreamNames(state) {
10215
+ const declared = state[STATE_STREAMS_NAME];
10216
+ if (typeof declared !== "object" || declared === null) {
10217
+ return new Set();
10218
+ }
10219
+ return new Set(Object.keys(declared));
10220
+ }
10221
+ function processDccDeclarations(state) {
10222
+ const bindables = readNameList(state, STATE_BINDABLES_NAME) ?? [];
10223
+ const commands = readNameList(state, STATE_COMMANDS_NAME) ?? [];
10224
+ const descriptors = getAllPropertyDescriptors(state);
10225
+ const streamNames = getStreamNames(state);
10226
+ const streamBackedBindables = [];
10227
+ for (const name of bindables) {
10228
+ const descriptor = descriptors[name];
10229
+ if (typeof descriptor === "undefined") {
10230
+ // `$streams` 由来なら実体化後に現れるので通す。アクセサはこちらで補う。
10231
+ if (streamNames.has(name)) {
10232
+ streamBackedBindables.push(name);
10233
+ continue;
10234
+ }
10235
+ raiseError(`${STATE_BINDABLES_NAME} entry "${name}" is not declared on the state.`);
10236
+ }
10237
+ if (typeof descriptor.value === "function") {
10238
+ raiseError(`${STATE_BINDABLES_NAME} entry "${name}" is a method. Declare it in ${STATE_COMMANDS_NAME} instead.`);
10239
+ }
10240
+ }
10241
+ for (const name of commands) {
10242
+ const descriptor = descriptors[name];
10243
+ if (typeof descriptor === "undefined") {
10244
+ raiseError(`${STATE_COMMANDS_NAME} entry "${name}" is not declared on the state.`);
10245
+ }
10246
+ if (typeof descriptor.value !== "function") {
10247
+ raiseError(`${STATE_COMMANDS_NAME} entry "${name}" is not a method. Declare it in ${STATE_BINDABLES_NAME} instead.`);
10248
+ }
10249
+ }
10250
+ return { bindables, commands, streamBackedBindables };
10251
+ }
10252
+
10253
+ function createWcBindable(tagName, bindables, commands = []) {
9489
10254
  const properties = bindables.map((propName) => ({
9490
10255
  name: propName,
9491
10256
  event: `${tagName}:${propName}-changed`,
10257
+ // Read the member off the element instead of trusting event.detail. The event is a
10258
+ // notification, not a carrier: a sub-path write (`user.name = "x"` against a `user`
10259
+ // member) has no single value to put in detail, and a state-side setter may normalize
10260
+ // what was written. Both cases are correct through the property.
10261
+ getter: (event) => event.target[propName],
9492
10262
  }));
9493
10263
  // Every $bindables member gets both a getter and a setter on the DCC prototype,
9494
10264
  // so declare it in inputs as well — a property declared only in `properties` is
@@ -9497,12 +10267,24 @@ function createWcBindable(tagName, bindables) {
9497
10267
  const inputs = bindables.map((propName) => ({
9498
10268
  name: propName,
9499
10269
  }));
9500
- return {
10270
+ const declaration = {
9501
10271
  protocol: "wc-bindable",
9502
10272
  version: 1,
9503
10273
  properties,
9504
10274
  inputs,
9505
10275
  };
10276
+ if (commands.length === 0) {
10277
+ return declaration;
10278
+ }
10279
+ // `async: true` is uniform on purpose: dccPropertyFactories.callFn always chains on the
10280
+ // inner <wcs-state>'s initializePromise, so a DCC command returns a Promise whether or not
10281
+ // the underlying state method was declared `async`. Reporting the state method's own
10282
+ // asyncness would describe something callers never observe.
10283
+ const declaredCommands = commands.map((name) => ({
10284
+ name,
10285
+ async: true,
10286
+ }));
10287
+ return { ...declaration, commands: declaredCommands };
9506
10288
  }
9507
10289
  function createBindableEventMap(tagName, bindables) {
9508
10290
  const map = {};
@@ -9519,20 +10301,20 @@ function defineDCC(hostElement, shadowRoot, state) {
9519
10301
  raiseError(`DCC: "${tagName}" is not a valid custom element name (must contain a hyphen).`);
9520
10302
  }
9521
10303
  if (customElements.get(tagName)) {
9522
- // 既に登録済みならスキップ(重複定義の検知のため警告は出す)
9523
- console.warn(`[@wcstack/state] DCC: "${tagName}" is already registered. Skipping redefinition.`);
9524
- return;
10304
+ // 重複定義は authoring error として落とす。従来は warn してスキップしていたが、
10305
+ // 先勝ちで別テンプレートのインスタンスが生えるため「動いているように見えて中身が違う」
10306
+ // 状態になる。state 名の重複(stateElementByName)が raiseError なのと作法を揃える
10307
+ // (docs/architecture-hardening/15-state-component-mechanism-consistency.md §3.4)。
10308
+ raiseError(`DCC: "${tagName}" is already registered. A custom element name can only be defined once.`);
9525
10309
  }
9526
10310
  // ShadowRoot は cloneNode 不可のため、template 経由で内容をクローン
9527
10311
  const template = document.createElement("template");
9528
10312
  template.innerHTML = shadowRoot.innerHTML;
9529
10313
  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)
10314
+ // $bindables / $commands から wcBindable + bindableEventMap を生成
10315
+ const { bindables, commands, streamBackedBindables } = processDccDeclarations(state);
10316
+ const wcBindable = (bindables.length > 0 || commands.length > 0)
10317
+ ? createWcBindable(tagName, bindables, commands)
9536
10318
  : null;
9537
10319
  const bindableEventMap = bindables.length > 0
9538
10320
  ? createBindableEventMap(tagName, bindables)
@@ -9545,27 +10327,79 @@ function defineDCC(hostElement, shadowRoot, state) {
9545
10327
  static wcBindable = wcBindable;
9546
10328
  static bindableEventMap = bindableEventMap;
9547
10329
  _shadow = null;
9548
- connectedCallback() {
10330
+ /**
10331
+ * shadow を遅延構築する。定義要素(`data-wc-definition`)では null を返す。
10332
+ *
10333
+ * connectedCallback ではなくここで張るのは、**接続前にアクセサが呼ばれる**ため
10334
+ * (§1.4)。`for` の全追加パスは行を fragment に組み立ててからバインドを適用し、
10335
+ * fragment を DOM に挿すのは最後なので、`element.count = v` の時点で行はまだ未接続。
10336
+ * shadow が無いと `stateElement` が null になり、setterFn が無言で書き込みを捨てていた。
10337
+ * ここで構築しておけば、書き込みは inner `<wcs-state>` の initializePromise に
10338
+ * 積まれ、接続・state ロード後に適用される。
10339
+ *
10340
+ * 冪等なので再接続でも張り直さない。shadow tree は host の切断後も保持され、
10341
+ * 2 回目の attachShadow は NotSupportedError になる(§1.3)。`if` の false→true
10342
+ * 再マウントと `for` の行プーリングはどちらも同一ノードを unmount → mount する。
10343
+ * closed mode では `this.shadowRoot` が null なので判定はフィールド側で行う。
10344
+ *
10345
+ * G4 は「constructor へ前倒し」で決着したが、実装は constructor ではなく
10346
+ * この遅延構築を採った。目的(未接続でもアクセサが動く)は同じで、constructor 版だと
10347
+ * (1) 定義要素の判定に属性を読む必要があり constructor の作法に反する、
10348
+ * (2) 同一タグの `data-wc-definition` が 2 つある場合、DSD の shadow を既に持つ
10349
+ * 2 つ目に attachShadow して throw する、の 2 点を踏むため。
10350
+ */
10351
+ _ensureShadow() {
10352
+ if (this._shadow !== null)
10353
+ return this._shadow;
9549
10354
  if (this.hasAttribute(DCC_DEFINITION_ATTRIBUTE))
9550
- return;
10355
+ return null;
9551
10356
  this._shadow = this.attachShadow({ mode: DCCElement.shadowRootMode });
9552
10357
  this._shadow.appendChild(DCCElement.template.content.cloneNode(true));
9553
- // bindableEventMap の設定
10358
+ // template.content は inert なテンプレート所有ドキュメントに属するため、その clone は
10359
+ // カスタム要素として upgrade されていない。ホストが接続済みなら appendChild の時点で
10360
+ // upgrade されるが、未接続の shadow に挿した場合は upgrade 契機が無く、内側の
10361
+ // <wcs-state> が素の HTMLElement のまま残って createState が生えない。明示的に upgrade する。
10362
+ const registry = getCustomElementRegistry();
10363
+ if (registry !== null) {
10364
+ upgradeCustomElement(registry, this._shadow);
10365
+ }
10366
+ return this._shadow;
10367
+ }
10368
+ connectedCallback() {
10369
+ const shadow = this._ensureShadow();
10370
+ if (shadow === null)
10371
+ return;
10372
+ // bindableEventMap の設定。
10373
+ // initializePromise は待たない。待つと state のロード完了まで map が空のままで、
10374
+ // $connectedCallback 内で行った初期変更が変更イベントを出さない
10375
+ // (docs/architecture-hardening/15-state-component-mechanism-consistency.md §2.7)。
10376
+ // setBindableEventMap はフィールド代入だけで state を参照しないので、
10377
+ // <wcs-state> の初期化前に呼んでも安全。
9554
10378
  if (Object.keys(DCCElement.bindableEventMap).length > 0) {
9555
- const stateEl = this._shadow.querySelector(stateTagSelector);
10379
+ const stateEl = shadow.querySelector(stateTagSelector);
9556
10380
  if (stateEl) {
9557
- stateEl.initializePromise.then(() => {
9558
- stateEl.setBindableEventMap(DCCElement.bindableEventMap);
9559
- });
10381
+ stateEl.setBindableEventMap(DCCElement.bindableEventMap);
10382
+ }
10383
+ else {
10384
+ // $bindables を宣言しているのに束ねる先が無い。stateTagSelector は
10385
+ // `:not([name])` なので name 付きの <wcs-state> は一致せず、この分岐に落ちると
10386
+ // 変更イベントが一切出ないまま静かに壊れる
10387
+ // (docs/architecture-hardening/15-state-component-mechanism-consistency.md §2.5)。
10388
+ 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
10389
  }
9561
10390
  }
9562
10391
  }
9563
10392
  get stateElement() {
9564
- return this._shadow?.querySelector(stateTagSelector);
10393
+ // 未接続でも shadow を構築して解決する(§1.4)。
10394
+ return (this._ensureShadow()?.querySelector(stateTagSelector) ?? null);
9565
10395
  }
9566
10396
  };
9567
- // state プロパティを走査して DCC クラスのプロトタイプにgetter/setter/methodを定義
9568
- const descriptors = Object.getOwnPropertyDescriptors(state);
10397
+ // state プロパティを走査して DCC クラスのプロトタイプにgetter/setter/methodを定義。
10398
+ // 走査範囲は State の getterPaths / setterPaths 収集と同じ「自身+プロトタイプチェーン」に
10399
+ // 揃える。own descriptor だけを見ていた頃は、クラスインスタンスや Object.create(proto) の
10400
+ // state で「getterPaths には載るのにアクセサが生えない」乖離が出ていた
10401
+ // (docs/architecture-hardening/15-state-component-mechanism-consistency.md §2.4)。
10402
+ const descriptors = getAllPropertyDescriptors(state);
9569
10403
  for (const [name, desc] of Object.entries(descriptors)) {
9570
10404
  if (isInternalProperty(name))
9571
10405
  continue;
@@ -9580,6 +10414,17 @@ function defineDCC(hostElement, shadowRoot, state) {
9580
10414
  }
9581
10415
  Object.defineProperty(DCCElement.prototype, name, newDesc);
9582
10416
  }
10417
+ // `$streams` の値プロパティはインスタンス側の processStreamsDeclaration で実体化されるため、
10418
+ // defineDCC の時点では state 上に descriptor が無い。$bindables に載っているのに
10419
+ // アクセサが生えないと宣言だけが生きて要素側が expando を掴むので、ここで補う(§2.3)。
10420
+ for (const name of streamBackedBindables) {
10421
+ Object.defineProperty(DCCElement.prototype, name, {
10422
+ configurable: true,
10423
+ enumerable: true,
10424
+ get: getterFn(name),
10425
+ set: setterFn(name),
10426
+ });
10427
+ }
9583
10428
  // カスタム要素登録
9584
10429
  customElements.define(tagName, DCCElement);
9585
10430
  }
@@ -9747,6 +10592,56 @@ function dirtyCacheEntryByAbsoluteStateAddress(address) {
9747
10592
  }
9748
10593
  }
9749
10594
 
10595
+ /**
10596
+ * webComponent/crossBoundaryAddress.ts
10597
+ *
10598
+ * mapped な `bind-component` の state は innerState proxy を target に持つ。
10599
+ * そこへの読み書きは `Reflect.get/set(target, path)` で行われるため、Proxy の
10600
+ * トラップに渡るのは**パス文字列だけ**で、解決済みの listIndex が落ちる。
10601
+ *
10602
+ * 子スコープが `for:` でマップ先の配列を回している場合、行バインディングが読む
10603
+ * `items.*.name` は親スコープの `rows.*.name` に翻訳されるが、どの行かは listIndex
10604
+ * にしか無い。ループ文脈はコンポーネント要素(親スコープ側)にぶら下がっており、
10605
+ * 子スコープのループはコンポーネントの内側なので `getLoopContextByNode` では引けない
10606
+ * (docs/architecture-hardening/15-state-component-mechanism-consistency.md §1.8)。
10607
+ *
10608
+ * そこで越境直前のアドレスを動的スコープで受け渡す。push/pop するのは
10609
+ * `hasMappedComponentState` が真の state 要素の読み書きだけで、通常の state の
10610
+ * ホットパス(getByAddress / setByAddress)には一切載らない。
10611
+ */
10612
+ // 行ごとの読み書きで通るため、エントリオブジェクトを割り当てずに 2 本の並行配列で持つ
10613
+ // (リスト描画は「行数 × 行内バインディング数」回ここを通る)。
10614
+ const stateElementStack = [];
10615
+ const addressStack = [];
10616
+ let depth = 0;
10617
+ function pushCrossBoundaryAddress(stateElement, address) {
10618
+ stateElementStack[depth] = stateElement;
10619
+ addressStack[depth] = address;
10620
+ depth++;
10621
+ }
10622
+ function popCrossBoundaryAddress() {
10623
+ depth--;
10624
+ // 参照を残さない(state 要素・listIndex を保持し続けないため)
10625
+ stateElementStack[depth] = undefined;
10626
+ addressStack[depth] = undefined;
10627
+ }
10628
+ /**
10629
+ * 越境直前のアドレスを取り出す。スタック最上位が「この state 要素の、このパスの
10630
+ * 読み書き」であるときだけ返す。ネストしたコンポーネントでは最内の越境が
10631
+ * 最上位になるため、同一性の照合だけで取り違えを防げる。
10632
+ */
10633
+ function getCrossBoundaryAddress(stateElement, path) {
10634
+ if (depth === 0) {
10635
+ return null;
10636
+ }
10637
+ const top = depth - 1;
10638
+ const address = addressStack[top];
10639
+ if (stateElementStack[top] !== stateElement || address?.pathInfo.path !== path) {
10640
+ return null;
10641
+ }
10642
+ return address;
10643
+ }
10644
+
9750
10645
  function checkDependency(handler, address) {
9751
10646
  // $untrackDependency スコープ中/setter 実行中は依存を張らない
9752
10647
  if (handler.untracking) {
@@ -9770,9 +10665,18 @@ function checkDependency(handler, address) {
9770
10665
  if (address.pathInfo.wildcardCount > 0 && lastInfo.wildcardCount > 0) {
9771
10666
  const sharedLen = calcWildcardLen(address.pathInfo, lastInfo);
9772
10667
  if (sharedLen > 0) {
10668
+ // 共有ワイルドカード段の突き合わせ。base 深さ Δ を持つ子スコープでは
10669
+ // 先頭起点だと Δ 段目(=親子で常に同一の base)を比べてしまい、
10670
+ // 本物の他行読み取りを取りこぼす。末尾起点で数える(list/wildcardLevel.ts)
9773
10671
  let crossRow = false;
10672
+ const hereChain = address.listIndex ?? null;
10673
+ const thereChain = lastAddress.listIndex ?? null;
9774
10674
  for (let level = 0; level < sharedLen; level++) {
9775
- if (address.listIndex?.at(level) !== lastAddress.listIndex?.at(level)) {
10675
+ const here = hereChain !== null
10676
+ ? listIndexAtWildcard(hereChain, level, address.pathInfo.wildcardCount) : null;
10677
+ const there = thereChain !== null
10678
+ ? listIndexAtWildcard(thereChain, level, lastInfo.wildcardCount) : null;
10679
+ if (here !== there) {
9776
10680
  crossRow = true;
9777
10681
  break;
9778
10682
  }
@@ -9789,6 +10693,28 @@ function checkDependency(handler, address) {
9789
10693
  }
9790
10694
  }
9791
10695
 
10696
+ /**
10697
+ * このアドレスの値をキャッシュしてよいか(getByAddress / setByAddress 共通の判定)。
10698
+ *
10699
+ * ワイルドカードを含むパス(リスト行)と宣言済み getter は再評価が高くつくため
10700
+ * キャッシュする。ただし mapped な `bind-component` の state は例外で、丸ごと外す。
10701
+ *
10702
+ * mapped な state は値を持たず、読みも書きも親スコープの state へ解決される
10703
+ * (innerState proxy)。同じ値は親側のキャッシュにも載り、その無効化は親の依存 walk が
10704
+ * 担う。子側にもう一段キャッシュを置くと、正本でない複製を親の無効化が届かない場所に
10705
+ * 作ることになり、親起点の書き込みのあと子だけが旧値を読み続ける。二重に持たないのが
10706
+ * 唯一の整合手段なので、mapped な state 要素ではキャッシュ層を持たない — 親の
10707
+ * キャッシュがそのまま効くので、失うのは重複していた一段だけ
10708
+ * (docs/architecture-hardening/15-state-component-mechanism-consistency.md §1.8)。
10709
+ */
10710
+ function isCacheable(stateElement, address) {
10711
+ if (stateElement.hasMappedComponentState === true) {
10712
+ return false;
10713
+ }
10714
+ return address.pathInfo.wildcardCount > 0 ||
10715
+ stateElement.getterPaths.has(address.pathInfo.path);
10716
+ }
10717
+
9792
10718
  /**
9793
10719
  * getByAddress.ts
9794
10720
  *
@@ -9856,6 +10782,17 @@ function _getByAddress(target, address, receiver, handler, stateElement) {
9856
10782
  handler.popAddress();
9857
10783
  }
9858
10784
  }
10785
+ else if (stateElement.hasMappedComponentState === true) {
10786
+ // target は innerState proxy。get トラップにはパス文字列しか渡らないので、
10787
+ // 解決済みの listIndex を動的スコープで越境させる(§1.8)
10788
+ pushCrossBoundaryAddress(stateElement, address);
10789
+ try {
10790
+ return Reflect.get(target, address.pathInfo.path);
10791
+ }
10792
+ finally {
10793
+ popCrossBoundaryAddress();
10794
+ }
10795
+ }
9859
10796
  else {
9860
10797
  return Reflect.get(target, address.pathInfo.path);
9861
10798
  }
@@ -9863,6 +10800,20 @@ function _getByAddress(target, address, receiver, handler, stateElement) {
9863
10800
  else {
9864
10801
  const parentAddress = address.parentAddress ?? raiseError(`address.parentAddress is undefined path: ${address.pathInfo.path}`);
9865
10802
  const parentValue = getByAddress(target, parentAddress, receiver, handler);
10803
+ // 親が居ないパスの読みは undefined(=「state に意見が無い」)。`Reflect.get` に
10804
+ // そのまま渡すと生の `TypeError: Reflect.get called on non-object` になり、
10805
+ // updater の drain も行ループも捕まえないので **1 本の stale な読みが同じバッチの
10806
+ // 無関係な更新まで道連れにする**(§1.7 / §1.9 と同じ構図)。
10807
+ //
10808
+ // 実際に踏むのは「消えた行を指すバインディングが、その行を消す `for` より先に
10809
+ // 適用される」形。同一スコープならトポロジカル順で `for` が先に来るので起きないが、
10810
+ // bind-component は親スコープの通知と子スコープの `for` が別経路で流れるため
10811
+ // 順序が保証されない(docs/state-bind-component-nested-for-design.md)。
10812
+ // undefined はプロパティ書き込みがスキップされる値なので DOM は触られず、
10813
+ // 直後に `for` が行ごと外して整合する。
10814
+ if (parentValue === null || typeof parentValue === "undefined") {
10815
+ return undefined;
10816
+ }
9866
10817
  const lastSegment = address.pathInfo.segments[address.pathInfo.segments.length - 1];
9867
10818
  if (lastSegment === WILDCARD) {
9868
10819
  const index = address.listIndex?.index ?? raiseError(`address.listIndex?.index is undefined path: ${address.pathInfo.path}`);
@@ -9892,8 +10843,7 @@ function getByAddress(target, address, receiver, handler) {
9892
10843
  // $streams の args トレース中のみ絶対アドレスを捕捉(collector 非活性なら即 return)
9893
10844
  collectStreamDependency(handler.stateElement, address);
9894
10845
  const stateElement = handler.stateElement;
9895
- const cacheable = address.pathInfo.wildcardCount > 0 ||
9896
- stateElement.getterPaths.has(address.pathInfo.path);
10846
+ const cacheable = isCacheable(stateElement, address);
9897
10847
  if (cacheable) {
9898
10848
  return _getByAddressWithCache(target, address, receiver, handler, stateElement);
9899
10849
  }
@@ -9931,7 +10881,181 @@ function getContextListIndex(handler, structuredPath) {
9931
10881
  if (typeof index === "undefined") {
9932
10882
  return null;
9933
10883
  }
9934
- return address.listIndex?.at(index) ?? null;
10884
+ if (address.listIndex === null) {
10885
+ return null;
10886
+ }
10887
+ return listIndexAtWildcard(address.listIndex, index, address.pathInfo.wildcardCount);
10888
+ }
10889
+
10890
+ /**
10891
+ * DCC の `$bindables` メンバの変更イベントを host に dispatch する。
10892
+ *
10893
+ * 対応するのは 3 通り。
10894
+ *
10895
+ * 1. **完全一致** — `count = 1` が `count` メンバを撃つ。`detail` は書き込んだ値。
10896
+ * 2. **サブパス** — `user.name = "x"` や `items.0.done = true` が `user` / `items` メンバを撃つ。
10897
+ * `$bindables` のエントリは常にフラットなトップレベル名(dotted 名は
10898
+ * processDccDeclarations の存在検査で落ちる)なので、先頭セグメントを見れば足りる。
10899
+ * この場合 `detail` は付かない — メンバ全体ではない値を載せると誤解を招くため。
10900
+ * 3. **`$postUpdate`** — in-place 変異を通知する正規の idiom。書き込んだ値が無いので `detail` は付かない。
10901
+ *
10902
+ * `detail` に頼らないのが正しい読み方で、`createWcBindable` は各 property に
10903
+ * `getter: (event) => event.target[name]` を宣言している。observer はイベントを
10904
+ * 「変わった」という通知として受け取り、値は要素から読む。
10905
+ *
10906
+ * 従来は完全一致しか見ておらず、`$bindables: ["user"]` で `user.name` を書いても
10907
+ * 発火しなかった。wc-bindable の `properties[].event` は「変更で発火する」契約なので乖離していた
10908
+ * (docs/architecture-hardening/15-state-component-mechanism-consistency.md §2.1)。
10909
+ *
10910
+ * 配列の in-place 変異(`items.push(...)`)は set トラップを通らないため、ここでも捕まらない。
10911
+ * これはリアクティブコア全体の規範(in-place 変異は `$postUpdate` で通知する)と同じで、
10912
+ * 正しい idiom を踏めば 3 で発火する。
10913
+ *
10914
+ * `$listKeys` を宣言したリストは、配列代入がキー突合後に per-path 書き込みへ分解されるため
10915
+ * (docs/state-list-key-design.md §2)、1 回の代入で `1 + 変化行数` 回発火する。値は要素から
10916
+ * 読む契約なので結果は変わらない。詳細は上記 §2.1 の「`$listKeys` との相互作用」。
10917
+ */
10918
+ function dispatchBindableEvent(stateElement, pathInfo, detail) {
10919
+ const map = stateElement.bindableEventMap;
10920
+ const exactEventName = map[pathInfo.path];
10921
+ const isExact = typeof exactEventName === "string";
10922
+ const eventName = isExact
10923
+ ? exactEventName
10924
+ : (pathInfo.segments.length > 1 ? map[pathInfo.segments[0]] : undefined);
10925
+ if (typeof eventName !== "string") {
10926
+ return;
10927
+ }
10928
+ const rootNode = stateElement.rootNode;
10929
+ if (!(rootNode instanceof ShadowRoot)) {
10930
+ return;
10931
+ }
10932
+ rootNode.host.dispatchEvent(new CustomEvent(eventName, {
10933
+ // 完全一致のときだけ、書き込んだ値をそのまま載せる(従来互換)。
10934
+ detail: isExact && typeof detail !== "undefined" ? detail.value : undefined,
10935
+ bubbles: true,
10936
+ }));
10937
+ }
10938
+
10939
+ /**
10940
+ * list/mergeKeyedList.ts
10941
+ *
10942
+ * `$listKeys` 宣言済みリストパスへの配列代入で、キーが一致する行の
10943
+ * 「オブジェクト強制・値展開」を行う(docs/state-list-key-design.md §2)。
10944
+ *
10945
+ * - キー突合し、一致行は**旧オブジェクトを据え置いた**ハイブリッド配列を作る
10946
+ * → 配列要素の参照が変わらないので for は行を再利用する(DOM・フォーカス・
10947
+ * 非バインド DOM 状態が保存される)
10948
+ * - 一致行の「変化したフィールド」だけを列挙して返す
10949
+ * → 呼び出し側が per-path 書き込みとして発行する(§7.0 の穴を塞ぐ正典イディオム)
10950
+ *
10951
+ * このモジュールは純粋な計算のみで、state への書き込みは行わない。
10952
+ */
10953
+ /**
10954
+ * 値展開は own enumerable データプロパティのコピーなので、プロトタイプや
10955
+ * アクセサを持つオブジェクトでは意味論が保てない。plain object 以外は即エラー(§5)。
10956
+ */
10957
+ function assertPlainRow(row, path, side, position) {
10958
+ if (typeof row !== "object" || row === null) {
10959
+ raiseError(`${STATE_LIST_KEYS_NAME} list "${path}": ${side} row at index ${position} must be a plain object ` +
10960
+ `(got ${row === null ? "null" : typeof row}).`);
10961
+ }
10962
+ const proto = Object.getPrototypeOf(row);
10963
+ if (proto !== Object.prototype && proto !== null) {
10964
+ raiseError(`${STATE_LIST_KEYS_NAME} list "${path}": ${side} row at index ${position} must be a plain object ` +
10965
+ `(class instances and exotic objects cannot be value-expanded).`);
10966
+ }
10967
+ }
10968
+ function keyOf(row, spec, path, side, position) {
10969
+ const key = typeof spec === "function" ? spec(row) : row[spec];
10970
+ if (key === undefined || key === null) {
10971
+ raiseError(`${STATE_LIST_KEYS_NAME} list "${path}": ${side} row at index ${position} has no key ` +
10972
+ `(${typeof spec === "function" ? "key function" : `field "${spec}"`} returned ${String(key)}).`);
10973
+ }
10974
+ return key;
10975
+ }
10976
+ /** 行を検証しつつキーを抽出する。キー重複は即エラー(§5)。 */
10977
+ function extractKeys(list, spec, path, side) {
10978
+ const keys = new Array(list.length);
10979
+ const seen = new Set();
10980
+ for (let i = 0; i < list.length; i++) {
10981
+ const row = list[i];
10982
+ assertPlainRow(row, path, side, i);
10983
+ const key = keyOf(row, spec, path, side, i);
10984
+ if (seen.has(key)) {
10985
+ raiseError(`${STATE_LIST_KEYS_NAME} list "${path}": duplicate key ${JSON.stringify(key)} in ${side} list.`);
10986
+ }
10987
+ seen.add(key);
10988
+ keys[i] = key;
10989
+ }
10990
+ return keys;
10991
+ }
10992
+ /**
10993
+ * キー突合してハイブリッド配列を組む。値展開すべき一致行が 1 つも無ければ null
10994
+ * (呼び出し側は従来どおりの書き込みへ倒す)。
10995
+ *
10996
+ * 突合対象の旧配列は「最後に適用された配列」ではなく**現在格納されている配列**。
10997
+ * ハイブリッド構築が格納配列の参照を保存するため、同一マイクロタスク内の連続
10998
+ * 書き込みでも適用時の diff と transitive に整合する(§6)。
10999
+ */
11000
+ function mergeKeyedList(path, spec, oldValue, newList) {
11001
+ // 宣言済みパスなら初回代入(旧配列なし)でも新配列を検証する。
11002
+ // 「2 回目の代入で初めて重複キーが露見する」という不連続を避けるため。
11003
+ const newKeys = extractKeys(newList, spec, path, "new");
11004
+ if (!Array.isArray(oldValue) || oldValue.length === 0 || newList.length === 0) {
11005
+ return null;
11006
+ }
11007
+ const oldList = oldValue;
11008
+ const oldKeys = extractKeys(oldList, spec, path, "current");
11009
+ const oldRowByKey = new Map();
11010
+ for (let i = 0; i < oldList.length; i++) {
11011
+ oldRowByKey.set(oldKeys[i], oldList[i]);
11012
+ }
11013
+ const list = new Array(newList.length);
11014
+ const matched = [];
11015
+ for (let i = 0; i < newList.length; i++) {
11016
+ const newRow = newList[i];
11017
+ const oldRow = oldRowByKey.get(newKeys[i]);
11018
+ if (typeof oldRow === "undefined" || oldRow === newRow) {
11019
+ // 追加行、または既に同一オブジェクト(生配列 in-place 変異 + コピー再代入の
11020
+ // イディオム)。後者は従来どおり walkDependency の全行フォールバックが担う。
11021
+ list[i] = newRow;
11022
+ continue;
11023
+ }
11024
+ list[i] = oldRow;
11025
+ matched.push({ position: i, oldRow, newRow });
11026
+ }
11027
+ return matched.length > 0 ? { list, matched } : null;
11028
+ }
11029
+ /**
11030
+ * 一致行について per-path 書き込みすべきフィールドを列挙する。
11031
+ *
11032
+ * - 変化したフィールドのみ(同値は書かない。無変化リフレッシュを完全なゼロコストに
11033
+ * するため — 全フィールド無条件書き込みだと §2.2 の利得が消える)
11034
+ * - 新行から消えた旧フィールドは **null** を書く(undefined ではない)
11035
+ *
11036
+ * 同値判定は Object.is。setByAddress の same-value guard と同じ基準にすることで、
11037
+ * 「発行したが guard に落とされる」無駄な書き込みを作らない。
11038
+ *
11039
+ * 消えたフィールドに null を使うのは、この処理系では undefined が
11040
+ * 「状態が値を持たない=無意見」であり applyChangeToProperty が書き込みごと
11041
+ * スキップするため(明示的なクリアの語彙は null)。undefined を書くと state 側は
11042
+ * 更新されるのに DOM だけ旧値のまま残り、まさに本機能が塞ごうとしている
11043
+ * stale を再導入してしまう。既に null / undefined のフィールドは DOM 上も
11044
+ * 空なので、クリア書き込み自体を発行しない。
11045
+ */
11046
+ function collectFieldWrites(oldRow, newRow) {
11047
+ const writes = [];
11048
+ for (const field of Object.keys(newRow)) {
11049
+ if (!Object.is(oldRow[field], newRow[field])) {
11050
+ writes.push({ field, value: newRow[field] });
11051
+ }
11052
+ }
11053
+ for (const field of Object.keys(oldRow)) {
11054
+ if (!Object.hasOwn(newRow, field) && oldRow[field] != null) {
11055
+ writes.push({ field, value: null });
11056
+ }
11057
+ }
11058
+ return writes;
9935
11059
  }
9936
11060
 
9937
11061
  /**
@@ -10100,7 +11224,7 @@ function _walkExpandWildcard(context, currentWildcardIndex, parentListIndex) {
10100
11224
  const parentAbsAddress = createAbsoluteStateAddress(parentAbsPathInfo, parentListIndex);
10101
11225
  const lastValue = getLastListValueByAbsoluteStateAddress(parentAbsAddress);
10102
11226
  const newValue = context.stateProxy[getByAddressSymbol](parentAddress);
10103
- const listDiff = createListDiff(parentAddress.listIndex, lastValue, newValue);
11227
+ const listDiff = createListDiff(getListParentListIndex(context.stateElement, parentAddress.listIndex), lastValue, newValue);
10104
11228
  const loopIndexes = getIndexes(listDiff, context.searchType);
10105
11229
  if (currentWildcardIndex === context.wildcardPaths.length - 1) {
10106
11230
  context.targetListIndexes.push(...loopIndexes);
@@ -10130,6 +11254,12 @@ function selectExpansionIndexes(context, sourcePath, _lastValue, _newValue, list
10130
11254
  if (listDiff.addIndexSet.size === 0 && listDiff.changeIndexSet.size === 0) {
10131
11255
  // 追加も移動も無い。削除も無ければ「変化が見えない再代入」= リフレッシュ意図
10132
11256
  if (listDiff.deleteIndexSet.size === 0) {
11257
+ // ただしキー突合による値展開が成立した書き込みでは、変化フィールドは
11258
+ // per-path 書き込みで個別に dirty 化済み。全行展開は純粋な無駄になる
11259
+ // (無変化ポーリングをゼロコストにする — 設計書 §2.2)。
11260
+ if (context.keyedMergePath === sourcePath) {
11261
+ return { fullRows: EMPTY_INDEXES, movedRows: null };
11262
+ }
10133
11263
  return { fullRows: listDiff.newIndexes, movedRows: null };
10134
11264
  }
10135
11265
  // 削除のみ: 残存行は位置も値も不変なので展開しない
@@ -10238,7 +11368,7 @@ function _collectDependencies(context, address, nextEntries) {
10238
11368
  const absPathInfo = getAbsolutePathInfo(context.stateElement, address.pathInfo);
10239
11369
  const absAddress = createAbsoluteStateAddress(absPathInfo, address.listIndex);
10240
11370
  const lastValue = getLastListValueByAbsoluteStateAddress(absAddress);
10241
- const listDiff = createListDiff(address.listIndex, lastValue, newValue);
11371
+ const listDiff = createListDiff(getListParentListIndex(context.stateElement, address.listIndex), lastValue, newValue);
10242
11372
  const selection = selectExpansionIndexes(context, sourcePath, lastValue, newValue, listDiff);
10243
11373
  for (const listIndex of selection.fullRows) {
10244
11374
  const depAddress = createStateAddress(depPathInfo, listIndex);
@@ -10311,7 +11441,7 @@ function _collectDependencies(context, address, nextEntries) {
10311
11441
  if (address.listIndex === null) {
10312
11442
  raiseError(`Cannot expand dynamic dependency with wildcard for non-list address: ${address.pathInfo.path}`);
10313
11443
  }
10314
- listIndex = address.listIndex.at(wildcardLen - 1);
11444
+ listIndex = listIndexAtWildcard(address.listIndex, wildcardLen - 1, address.pathInfo.wildcardCount);
10315
11445
  }
10316
11446
  else {
10317
11447
  // selectedIndex => items.*.selected
@@ -10336,7 +11466,7 @@ function _collectDependencies(context, address, nextEntries) {
10336
11466
  if (address.listIndex === null) {
10337
11467
  raiseError(`Cannot expand dynamic dependency with wildcard for non-list address: ${address.pathInfo.path}`);
10338
11468
  }
10339
- const listIndex = address.listIndex.at(wildcardLen - 1);
11469
+ const listIndex = listIndexAtWildcard(address.listIndex, wildcardLen - 1, address.pathInfo.wildcardCount);
10340
11470
  listIndexes.push(listIndex);
10341
11471
  }
10342
11472
  }
@@ -10378,6 +11508,7 @@ function walkDependency(stateName, stateElement, startAddress, staticDependency,
10378
11508
  stateProxy: stateProxy,
10379
11509
  searchType: searchType,
10380
11510
  listExpansion: options?.listExpansion ?? "full",
11511
+ keyedMergePath: options?.keyedMergePath ?? null,
10381
11512
  };
10382
11513
  _walkDependency(context, startAddress, callback);
10383
11514
  return Array.from(context.result);
@@ -10404,7 +11535,7 @@ function walkDependency(stateName, stateElement, startAddress, staticDependency,
10404
11535
  // binding 経由の書き込みは呼び出し元の dynamic scope から context を引き継ぎ、
10405
11536
  // binding 外からの API update は新しい transaction を開始する(設計書 §4 規則 1)。
10406
11537
  // 依存 walk で enqueue される派生アドレスも同じ書き込みの因果に属する。
10407
- function notifyWrite(address, absAddress, receiver, handler) {
11538
+ function notifyWrite(address, absAddress, receiver, handler, keyedMergePath) {
10408
11539
  const propagationContext = config.enablePropagationContext
10409
11540
  ? (getCurrentPropagationContext() ?? beginPropagationTransaction(-1))
10410
11541
  : null;
@@ -10423,9 +11554,9 @@ function notifyWrite(address, absAddress, receiver, handler) {
10423
11554
  },
10424
11555
  // リスト置換時は追加行・位置変更行のみ展開する(未変更行の再訪を省く。
10425
11556
  // $postUpdate の手動リフレッシュは従来通り全行展開のまま)
10426
- { listExpansion: "diff" });
11557
+ { listExpansion: "diff", keyedMergePath });
10427
11558
  }
10428
- function _setByAddress(target, address, absAddress, value, receiver, handler) {
11559
+ function _setByAddress(target, address, absAddress, value, receiver, handler, keyedMergePath) {
10429
11560
  try {
10430
11561
  if (address.pathInfo.path in target) {
10431
11562
  if (handler.stateElement.setterPaths.has(address.pathInfo.path)) {
@@ -10444,6 +11575,17 @@ function _setByAddress(target, address, absAddress, value, receiver, handler) {
10444
11575
  handler.popAddress();
10445
11576
  }
10446
11577
  }
11578
+ else if (handler.stateElement.hasMappedComponentState === true) {
11579
+ // target は innerState proxy。set トラップにはパス文字列しか渡らないので、
11580
+ // 解決済みの listIndex を動的スコープで越境させる(§1.8)
11581
+ pushCrossBoundaryAddress(handler.stateElement, address);
11582
+ try {
11583
+ return Reflect.set(target, address.pathInfo.path, value);
11584
+ }
11585
+ finally {
11586
+ popCrossBoundaryAddress();
11587
+ }
11588
+ }
10447
11589
  else {
10448
11590
  return Reflect.set(target, address.pathInfo.path, value);
10449
11591
  }
@@ -10465,10 +11607,10 @@ function _setByAddress(target, address, absAddress, value, receiver, handler) {
10465
11607
  }
10466
11608
  }
10467
11609
  finally {
10468
- notifyWrite(address, absAddress, receiver, handler);
11610
+ notifyWrite(address, absAddress, receiver, handler, keyedMergePath);
10469
11611
  }
10470
11612
  }
10471
- function _setByAddressWithSwap(target, address, absAddress, value, receiver, handler) {
11613
+ function _setByAddressWithSwap(target, address, absAddress, value, receiver, handler, keyedMergePath) {
10472
11614
  // elementsの場合はswapInfoを準備
10473
11615
  let parentAddress = address.parentAddress ?? raiseError(`address.parentAddress is undefined path: ${address.pathInfo.path}`);
10474
11616
  let swapInfo = getSwapInfoByAddress(parentAddress);
@@ -10481,7 +11623,7 @@ function _setByAddressWithSwap(target, address, absAddress, value, receiver, han
10481
11623
  setSwapInfoByAddress(parentAddress, swapInfo);
10482
11624
  }
10483
11625
  try {
10484
- return _setByAddress(target, address, absAddress, value, receiver, handler);
11626
+ return _setByAddress(target, address, absAddress, value, receiver, handler, keyedMergePath);
10485
11627
  }
10486
11628
  finally {
10487
11629
  const index = swapInfo.value.indexOf(value);
@@ -10504,7 +11646,80 @@ function _setByAddressWithSwap(target, address, absAddress, value, receiver, han
10504
11646
  }
10505
11647
  }
10506
11648
  }
11649
+ /**
11650
+ * `$listKeys` 宣言済みリストパスへの配列代入を「キー一致行のオブジェクト値展開」に
11651
+ * 変換する(docs/state-list-key-design.md §2)。
11652
+ *
11653
+ * 1. キー突合して、一致行は旧オブジェクトを据え置いたハイブリッド配列を作る
11654
+ * 2. ハイブリッド配列を通常の書き込み経路で格納する
11655
+ * 3. createListDiff で listIndex を確定し、変化フィールドだけを per-path 書き込みで発行
11656
+ *
11657
+ * 3 を格納後に行うのが要点。フィールド書き込みは `list.*.field` を親経由で解決する
11658
+ * ため、親(ハイブリッド配列)が既に格納されていなければ正しい行に届かない。
11659
+ * また per-path 書き込みは再び setByAddress に入るので、ネストしたリストパスが
11660
+ * 宣言されていればそのレベルのキー突合が再帰的に走る(§4)。
11661
+ *
11662
+ * 未宣言時のコストは stateElement.listKeys の null 判定 1 回のみ(§7-1)。
11663
+ */
11664
+ function setKeyedListByAddress(target, address, merge, oldList, receiver, handler) {
11665
+ const listPath = address.pathInfo.path;
11666
+ // diff の基準は「マージ相手にした配列」= 書き込み直前に格納されていた配列。
11667
+ // 読み手(applyChangeToFor / $getAll / resolve)は現在格納されている配列の
11668
+ // listIndex 台帳(listIndexesByList)へ収束するため、同じ基準で引くことで
11669
+ // 書き込みが dirty 化・キャッシュするアドレスと読み手のアドレスが一致する。
11670
+ // lastValue(最後に *適用* された配列)を基準にすると、for が未マウントで
11671
+ // lastValue が空のときに別台帳を作ってしまい、値は入っているのにワイルドカード
11672
+ // 読みだけ旧値のまま残る(設計書 §8.1)。
11673
+ // 格納より前に引くのは、格納時の walkDependency(listExpansion: "diff")が
11674
+ // 先にハイブリッド配列の台帳を作ってしまうと、後から上書きした台帳との間で
11675
+ // 同じ分裂が起きるため。先に確定させておけば以降は全経路がこれに合流する。
11676
+ const listParentListIndex = getListParentListIndex(handler.stateElement, address.listIndex);
11677
+ if (getListIndexesByList(oldList) === null) {
11678
+ // 一度も描画されていないリストは台帳自体が無い。先に生やしておかないと
11679
+ // isSameList 経路が空の oldIndexes をそのまま新台帳にしてしまう。
11680
+ createListDiff(listParentListIndex, null, oldList);
11681
+ }
11682
+ const diff = createListDiff(listParentListIndex, oldList, merge.list);
11683
+ const result = setByAddressCore(target, address, merge.list, receiver, handler, listPath);
11684
+ const elementPathInfo = getPathInfo(listPath + DELIMITER + WILDCARD);
11685
+ for (const match of merge.matched) {
11686
+ const fieldWrites = collectFieldWrites(match.oldRow, match.newRow);
11687
+ if (fieldWrites.length === 0) {
11688
+ continue;
11689
+ }
11690
+ // createListDiff の契約上 newIndexes の長さはハイブリッド配列と一致するため
11691
+ // 通常 undefined にはならない。仮に不変条件が破れても、per-path 書き込みを
11692
+ // 諦めるだけで値そのものは行オブジェクトへ反映する(skip すると state だけが
11693
+ // 旧値のまま残り、本機能が塞ごうとしている stale を作ってしまう)。
11694
+ const listIndex = diff.newIndexes[match.position];
11695
+ for (const write of fieldWrites) {
11696
+ if (typeof listIndex === "undefined") {
11697
+ match.oldRow[write.field] = write.value;
11698
+ continue;
11699
+ }
11700
+ const fieldPathInfo = getPathInfo(elementPathInfo.path + DELIMITER + write.field);
11701
+ const fieldAddress = createStateAddress(fieldPathInfo, listIndex);
11702
+ setByAddress(target, fieldAddress, write.value, receiver, handler);
11703
+ }
11704
+ }
11705
+ return result;
11706
+ }
10507
11707
  function setByAddress(target, address, value, receiver, handler) {
11708
+ const listKeys = handler.stateElement.listKeys;
11709
+ if (listKeys != null && Array.isArray(value)) {
11710
+ const keySpec = listKeys.get(address.pathInfo.path);
11711
+ if (typeof keySpec !== "undefined") {
11712
+ const oldValue = getByAddress(target, address, receiver, handler);
11713
+ const merge = mergeKeyedList(address.pathInfo.path, keySpec, oldValue, value);
11714
+ if (merge !== null) {
11715
+ // merge が非 null なのは oldValue が非空配列のときだけ(mergeKeyedList 参照)
11716
+ return setKeyedListByAddress(target, address, merge, oldValue, receiver, handler);
11717
+ }
11718
+ }
11719
+ }
11720
+ return setByAddressCore(target, address, value, receiver, handler, null);
11721
+ }
11722
+ function setByAddressCore(target, address, value, receiver, handler, keyedMergePath) {
10508
11723
  const stateElement = handler.stateElement;
10509
11724
  const path = address.pathInfo.path;
10510
11725
  // occurrence(wc-bindable の `semantics: "event"`)由来の書き込みは、同値でも
@@ -10537,8 +11752,7 @@ function setByAddress(target, address, value, receiver, handler) {
10537
11752
  devOldValue = oldValue;
10538
11753
  devHasOldValue = true;
10539
11754
  }
10540
- const cacheable = address.pathInfo.wildcardCount > 0 ||
10541
- stateElement.getterPaths.has(path);
11755
+ const cacheable = isCacheable(stateElement, address);
10542
11756
  const absPathInfo = getAbsolutePathInfo(stateElement, address.pathInfo);
10543
11757
  const absAddress = createAbsoluteStateAddress(absPathInfo, address.listIndex);
10544
11758
  if (devtoolsSink !== null) {
@@ -10557,24 +11771,15 @@ function setByAddress(target, address, value, receiver, handler) {
10557
11771
  return Reflect.set(parentValue, key, value);
10558
11772
  }
10559
11773
  finally {
10560
- notifyWrite(address, absAddress, receiver, handler);
11774
+ notifyWrite(address, absAddress, receiver, handler, keyedMergePath);
10561
11775
  if (cacheable) {
10562
11776
  setCacheEntryByAbsoluteStateAddress(absAddress, {
10563
11777
  value: value,
10564
11778
  dirty: false
10565
11779
  });
10566
11780
  }
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
- }
11781
+ // DCC bindable イベントディスパッチ(完全一致 + サブパス → 先頭セグメント、§2.1)
11782
+ dispatchBindableEvent(stateElement, address.pathInfo, { value });
10578
11783
  }
10579
11784
  }
10580
11785
  }
@@ -10597,8 +11802,7 @@ function setByAddress(target, address, value, receiver, handler) {
10597
11802
  }
10598
11803
  // --- end same-value guard ---
10599
11804
  const isSwappable = stateElement.elementPaths.has(address.pathInfo.path);
10600
- const cacheable = address.pathInfo.wildcardCount > 0 ||
10601
- stateElement.getterPaths.has(address.pathInfo.path);
11805
+ const cacheable = isCacheable(stateElement, address);
10602
11806
  const absPathInfo = getAbsolutePathInfo(stateElement, address.pathInfo);
10603
11807
  const absAddress = createAbsoluteStateAddress(absPathInfo, address.listIndex);
10604
11808
  if (devtoolsSink !== null) {
@@ -10612,10 +11816,10 @@ function setByAddress(target, address, value, receiver, handler) {
10612
11816
  }
10613
11817
  try {
10614
11818
  if (isSwappable) {
10615
- return _setByAddressWithSwap(target, address, absAddress, value, receiver, handler);
11819
+ return _setByAddressWithSwap(target, address, absAddress, value, receiver, handler, keyedMergePath);
10616
11820
  }
10617
11821
  else {
10618
- return _setByAddress(target, address, absAddress, value, receiver, handler);
11822
+ return _setByAddress(target, address, absAddress, value, receiver, handler, keyedMergePath);
10619
11823
  }
10620
11824
  }
10621
11825
  finally {
@@ -10625,17 +11829,8 @@ function setByAddress(target, address, value, receiver, handler) {
10625
11829
  dirty: false
10626
11830
  });
10627
11831
  }
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
- }
11832
+ // DCC bindable イベントディスパッチ(完全一致 + サブパス → 先頭セグメント、§2.1)
11833
+ dispatchBindableEvent(stateElement, address.pathInfo, { value });
10639
11834
  }
10640
11835
  }
10641
11836
 
@@ -10683,8 +11878,10 @@ function resolve(target, _prop, receiver, handler) {
10683
11878
  raiseError(`ListIndexes not found: ${wildcardParentPathInfo.path}`);
10684
11879
  }
10685
11880
  const index = indexes[i];
11881
+ // 範囲外 index はリスト自体の不在と別原因なので index を含める
11882
+ // (docs/state-bind-component-nested-for-design.md §8.4)
10686
11883
  listIndex = listIndexes[index] ??
10687
- raiseError(`ListIndex not found: ${wildcardParentPathInfo.path}`);
11884
+ raiseError(`ListIndex not found at index ${index} of ${wildcardParentPathInfo.path}`);
10688
11885
  }
10689
11886
  // ToDo:WritableかReadonlyかを判定して適切なメソッドを呼び出す
10690
11887
  const address = createStateAddress(pathInfo, listIndex);
@@ -10726,7 +11923,7 @@ function getAll(target, prop, receiver, handler) {
10726
11923
  const wildcardPattern = pathInfo.wildcardParentPathInfos[i];
10727
11924
  const listIndex = getContextListIndex(handler, wildcardPattern.path);
10728
11925
  if (listIndex) {
10729
- indexes = listIndex.indexes;
11926
+ indexes = getScopedIndexes(listIndex, listIndex.length - getBaseDepth(handler.stateElement));
10730
11927
  break;
10731
11928
  }
10732
11929
  }
@@ -10743,7 +11940,7 @@ function getAll(target, prop, receiver, handler) {
10743
11940
  const wildcardAddress = createStateAddress(wildcardParentPathInfo, listIndex);
10744
11941
  const oldValue = lastValueByListAddress.get(wildcardAddress);
10745
11942
  const newValue = getByAddress(target, wildcardAddress, receiver, handler);
10746
- const listDiff = createListDiff(listIndex, oldValue, newValue);
11943
+ const listDiff = createListDiff(getListParentListIndex(handler.stateElement, listIndex), oldValue, newValue);
10747
11944
  const listIndexes = listDiff.newIndexes;
10748
11945
  const index = indexes[indexPos] ?? null;
10749
11946
  newValueByAddress.set(wildcardAddress, newValue);
@@ -10754,8 +11951,10 @@ function getAll(target, prop, receiver, handler) {
10754
11951
  }
10755
11952
  }
10756
11953
  else {
11954
+ // 範囲外 index はリスト自体の不在と別原因なので index を含める
11955
+ // (docs/state-bind-component-nested-for-design.md §8.4)
10757
11956
  const listIndex = listIndexes[index] ??
10758
- raiseError(`ListIndex not found: ${wildcardParentPathInfo.path}`);
11957
+ raiseError(`ListIndex not found at index ${index} of ${wildcardParentPathInfo.path}`);
10759
11958
  if ((wildcardIndexPos + 1) < wildcardParentPathInfos.length) {
10760
11959
  walkWildcardPattern(wildcardParentPathInfos, wildcardIndexPos + 1, listIndex, indexes, indexPos + 1, parentIndexes.concat(listIndex.index), results);
10761
11960
  }
@@ -10817,8 +12016,11 @@ function getListIndex(target, resolvedAddress, receiver, handler) {
10817
12016
  raiseError(`ListIndex not found: ${wildcardParentPathInfo.path}`);
10818
12017
  const wildcardIndex = resolvedAddress.wildcardIndexes[i] ??
10819
12018
  raiseError(`wildcardIndex is null: ${resolvedAddress.pathInfo.path}`);
12019
+ // 範囲外 index はリスト自体の不在と別原因なので、メッセージに index を含める。
12020
+ // 親パスだけを名指しすると「リスト自体が見つからない」と誤読させる
12021
+ // (docs/state-bind-component-nested-for-design.md §8.4)。
10820
12022
  parentListIndex = wildcardParentListIndexes[wildcardIndex] ??
10821
- raiseError(`ListIndex not found: ${wildcardParentPathInfo.path}`);
12023
+ raiseError(`ListIndex not found at index ${wildcardIndex} of ${wildcardParentPathInfo.path}`);
10822
12024
  }
10823
12025
  return parentListIndex;
10824
12026
  }
@@ -10847,6 +12049,10 @@ function postUpdate(target, _prop, receiver, handler) {
10847
12049
  // 更新対象として登録
10848
12050
  updater.enqueueAbsoluteAddress(absDepAddress);
10849
12051
  });
12052
+ // DCC bindable イベントディスパッチ。$postUpdate は in-place 変異を通知する正規の idiom で、
12053
+ // set トラップを通らない変更が観測面に出る唯一の経路なので、ここでも撃つ
12054
+ // (docs/architecture-hardening/15-state-component-mechanism-consistency.md §2.1)。
12055
+ dispatchBindableEvent(stateElement, address.pathInfo);
10850
12056
  };
10851
12057
  }
10852
12058
 
@@ -10951,7 +12157,7 @@ function updatedCallback(target, refs, receiver, handler) {
10951
12157
  }
10952
12158
  paths.add(pathName);
10953
12159
  if (pathInfo.wildcardCount > 0) {
10954
- const indexes = ref.listIndex.indexes ?? [];
12160
+ const indexes = getScopedIndexes(ref.listIndex, pathInfo.wildcardCount);
10955
12161
  const indexesList = indexesListByPath[pathName];
10956
12162
  if (typeof indexesList === "undefined") {
10957
12163
  indexesListByPath[pathName] = [indexes];
@@ -11068,7 +12274,13 @@ function get(target, prop, receiver, handler) {
11068
12274
  handler.stateElement.addIndexDependentGetterPath?.(lastInfo.path);
11069
12275
  }
11070
12276
  const listIndex = lastAddress?.listIndex;
11071
- return listIndex?.indexes[index] ?? raiseError(`ListIndex not found: ${prop.toString()}`);
12277
+ if (typeof listIndex === "undefined" || listIndex === null) {
12278
+ raiseError(`ListIndex not found: ${prop.toString()}`);
12279
+ }
12280
+ // `$1` は「このスコープの」1 段目。base 深さ Δ を持つ子スコープでも
12281
+ // 番号がずれないよう末尾から数える(list/wildcardLevel.ts)
12282
+ const indexListIndex = listIndexAtWildcard(listIndex, index, lastAddress.pathInfo.wildcardCount);
12283
+ return indexListIndex?.index ?? raiseError(`ListIndex not found: ${prop.toString()}`);
11072
12284
  }
11073
12285
  if (typeof prop === "string") {
11074
12286
  if (prop[0] === '$') {
@@ -11305,138 +12517,6 @@ function createStateProxy(rootNode, state, stateName, mutability) {
11305
12517
  return stateProxy;
11306
12518
  }
11307
12519
 
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
12520
  function cloneWithDescriptors(obj) {
11441
12521
  const proto = Object.getPrototypeOf(obj);
11442
12522
  const clone = Object.create(proto);
@@ -11461,6 +12541,39 @@ class InnerStateProxyHandler {
11461
12541
  this._webComponent = webComponent;
11462
12542
  this._innerStateElement = getStateElementByWebComponent(webComponent, stateName) ?? raiseError('State element not found for web component.');
11463
12543
  }
12544
+ /**
12545
+ * 親スコープで読み書きするときのループ文脈を決める。候補は 2 つある。
12546
+ *
12547
+ * 1. **越境直前のアドレスの listIndex**。子スコープの `for` が回している行
12548
+ * (§1.8)。子の listIndex は base(=ホストの親スコープ行)を親に持つので
12549
+ * チェーン長は Δ+W_inner = W_outer になり、そのまま外側の文脈として使える
12550
+ * (docs/state-bind-component-nested-for-design.md)。
12551
+ * 2. **コンポーネント要素のノードループ文脈**。コンポーネント自身が親の `for` の
12552
+ * 中にいるが、読んでいるパスは子スコープのループの外という形(`state.row: rows.*`)。
12553
+ *
12554
+ * 1 を先に見るのは、内側ほど具体的だから。入れ子形では 2 も非 null(=Δ 段だけ)に
12555
+ * なるが、それでは外側パスの段数に足りない。段数が一致する候補だけを採るのが
12556
+ * 判定の本体で、両方外れたら null(親側の解決に委ね、解けなければ raiseError。
12557
+ * 無言の取り違えを作らない)。
12558
+ */
12559
+ _outerLoopContext(innerPathInfo, outerAbsPathInfo) {
12560
+ const outerWildcardCount = outerAbsPathInfo.pathInfo.wildcardCount;
12561
+ const nodeLoopContext = getLoopContextByNode(this._webComponent);
12562
+ if (nodeLoopContext !== null && nodeLoopContext.listIndex.length === outerWildcardCount) {
12563
+ return nodeLoopContext;
12564
+ }
12565
+ if (outerWildcardCount > 0) {
12566
+ const address = getCrossBoundaryAddress(this._innerStateElement, innerPathInfo.path);
12567
+ const listIndex = address?.listIndex ?? null;
12568
+ if (listIndex !== null && listIndex.length === outerWildcardCount) {
12569
+ const outerWildcardPath = outerAbsPathInfo.pathInfo.wildcardPaths[outerWildcardCount - 1];
12570
+ return createStateAddress(getPathInfo(outerWildcardPath), listIndex);
12571
+ }
12572
+ }
12573
+ // どちらも段数が合わない。従来どおりノードの文脈へフォールバックし、
12574
+ // 解けなければ後段が raiseError する(無言の取り違えを作らない)
12575
+ return nodeLoopContext;
12576
+ }
11464
12577
  get(target, prop, receiver) {
11465
12578
  if (typeof prop === 'string') {
11466
12579
  if (prop === "then") {
@@ -11479,21 +12592,11 @@ class InnerStateProxyHandler {
11479
12592
  const innerAbsPathInfo = getAbsolutePathInfo(this._innerStateElement, innerPathInfo);
11480
12593
  const outerAbsPathInfo = getOuterAbsolutePathInfo(this._webComponent, innerAbsPathInfo);
11481
12594
  if (outerAbsPathInfo !== null) {
11482
- const loopContext = getLoopContextByNode(this._webComponent);
12595
+ const loopContext = this._outerLoopContext(innerPathInfo, outerAbsPathInfo);
11483
12596
  let value = undefined;
11484
12597
  outerAbsPathInfo.stateElement.createState("readonly", (state) => {
11485
12598
  state[setLoopContextSymbol](loopContext, () => {
11486
12599
  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
12600
  });
11498
12601
  });
11499
12602
  return value;
@@ -11520,7 +12623,7 @@ class InnerStateProxyHandler {
11520
12623
  const innerAbsPathInfo = getAbsolutePathInfo(this._innerStateElement, innerPathInfo);
11521
12624
  const outerAbsPathInfo = getOuterAbsolutePathInfo(this._webComponent, innerAbsPathInfo);
11522
12625
  if (outerAbsPathInfo !== null) {
11523
- const loopContext = getLoopContextByNode(this._webComponent);
12626
+ const loopContext = this._outerLoopContext(innerPathInfo, outerAbsPathInfo);
11524
12627
  outerAbsPathInfo.stateElement.createState("writable", (state) => {
11525
12628
  state[setLoopContextSymbol](loopContext, () => {
11526
12629
  state[outerAbsPathInfo.pathInfo.path] = value;
@@ -11588,42 +12691,24 @@ function createInnerState(webComponent, stateName) {
11588
12691
  return new Proxy(meltFrozenObject(state), handler);
11589
12692
  }
11590
12693
 
12694
+ /**
12695
+ * コンポーネントの `bind-component` プロパティとして露出する proxy。
12696
+ * read / write とも子の state proxy へ素通しする。
12697
+ *
12698
+ * mapped(親から `<prop>.*` をバインドされている)ケースでは、素通し先の
12699
+ * innerState proxy がマッピング規則に従って親 state へ解決するので、
12700
+ * `this.state.msg` の読みは親の現在値になり、書きは親 state へ届く。
12701
+ * plain(親からのバインドなし)ケースでは子のローカル state に解決する。
12702
+ * **どちらでも同じ意味論になる**のが要点
12703
+ * (docs/architecture-hardening/15-state-component-mechanism-consistency.md §1.1 / G1)。
12704
+ *
12705
+ * 以前は mapped 専用に「read = 最後に観測した値のキャッシュ/write = 値を捨てて
12706
+ * `$postUpdate` 通知のみ」という別 proxy を当てていた。あれは親 → 子の再読込通知という
12707
+ * **内部チャネル**としては正しかったが、それが公開 API を兼ねていたため、同じ
12708
+ * コンポーネント実装が親ページの書き方で挙動を変えていた。内部チャネルは
12709
+ * `applyChangeToWebComponent` が state element を直接引く形へ分離した。
12710
+ */
11591
12711
  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
12712
  _innerStateElement;
11628
12713
  constructor(webComponent, stateName) {
11629
12714
  this._innerStateElement = getStateElementByWebComponent(webComponent, stateName) ?? raiseError('State element not found for web component.');
@@ -11652,36 +12737,44 @@ class PlainOuterStateProxyHandler {
11652
12737
  }
11653
12738
  }
11654
12739
  }
11655
- function createPlainOuterState(webComponent, stateName) {
11656
- const handler = new PlainOuterStateProxyHandler(webComponent, stateName);
12740
+ function createOuterState(webComponent, stateName) {
12741
+ const handler = new OuterStateProxyHandler(webComponent, stateName);
11657
12742
  return new Proxy({}, handler);
11658
12743
  }
11659
12744
 
11660
12745
  const getOuter = (outerState) => () => outerState;
11661
12746
  function bindWebComponent(innerStateElement, component, stateProp, state) {
11662
12747
  setStateElementByWebComponent(component, stateProp, innerStateElement);
11663
- if (component.hasAttribute(config.bindAttributeName)) {
11664
- const bindings = (getBindingsByNode(component) ?? []).filter(binding => binding.propSegments[0] === stateProp);
12748
+ // 分岐は「data-wcs 属性の有無」ではなく「<stateProp>.* バインドが 1 件以上あるか」で決める。
12749
+ // 属性はあってもマッピング対象が 0 件(例: data-wcs="class.on: flag" だけ)の場合、
12750
+ // buildPrimaryMappingRule は primaryMappingRule を 1 件も作らないまま return するため、
12751
+ // outerState の lastValue / $postUpdate 意味論だけが残る。その状態では
12752
+ // component[stateProp] の read が常に undefined・write が完全な no-op になる
12753
+ // (docs/architecture-hardening/15-state-component-mechanism-consistency.md §1.2)。
12754
+ const bindings = component.hasAttribute(config.bindAttributeName)
12755
+ ? (getBindingsByNode(component) ?? []).filter(binding => binding.propSegments[0] === stateProp)
12756
+ : [];
12757
+ // 分岐が決めるのは「子の state の中身」だけ。mapped なら親 state へ解決する
12758
+ // innerState proxy、plain なら melt 済みのローカル state。
12759
+ if (bindings.length > 0) {
11665
12760
  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
- });
12761
+ // 値の正本が親スコープにあることを state 要素に記録する。越境アドレスの受け渡しと
12762
+ // リストパスの外向き伝播はこのフラグでのみ有効になる(§1.8)。
12763
+ innerStateElement.markComponentStateMapped?.();
12764
+ innerStateElement.setInitialState(createInnerState(component, stateProp));
11674
12765
  }
11675
12766
  else {
11676
12767
  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
12768
  }
11684
- markWebComponentAsComplete(component, innerStateElement);
12769
+ // 外向きに露出する proxy は両者で同一。mapped でも read はライブ・write は
12770
+ // innerState 経由で親 state に届く(§1.1 / G1)。
12771
+ const outerState = createOuterState(component, stateProp);
12772
+ Object.defineProperty(component, stateProp, {
12773
+ get: getOuter(outerState),
12774
+ enumerable: true,
12775
+ configurable: true,
12776
+ });
12777
+ markWebComponentAsComplete(component, stateProp);
11685
12778
  if (WEBCOMPONENT_STATE_READY_CALLBACK_NAME in component) {
11686
12779
  const func = component[WEBCOMPONENT_STATE_READY_CALLBACK_NAME];
11687
12780
  if (typeof func === 'function') {
@@ -11695,15 +12788,6 @@ function bindWebComponent(innerStateElement, component, stateProp, state) {
11695
12788
  }
11696
12789
  }
11697
12790
 
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
12791
  function getStateInfo(state) {
11708
12792
  const getterPaths = new Set();
11709
12793
  const setterPaths = new Set();
@@ -11739,15 +12823,17 @@ class State extends HTMLElementBase {
11739
12823
  _resolveInitialize = null;
11740
12824
  _connectedCallbackPromise;
11741
12825
  _resolveConnectedCallback = null;
12826
+ _rejectConnectedCallback = null;
11742
12827
  _loadingPromise;
11743
12828
  _resolveLoading = null;
11744
12829
  _setStatePromise = null;
11745
12830
  _resolveSetState = null;
11746
12831
  _listPaths = new Set();
12832
+ _listKeys = null;
11747
12833
  _elementPaths = new Set();
11748
12834
  _getterPaths = new Set();
11749
12835
  _setterPaths = new Set();
11750
- _loopContextStack = createLoopContextStack();
12836
+ _loopContextStack = createLoopContextStack(() => getBaseDepth(this));
11751
12837
  _dynamicDependency = new Map();
11752
12838
  _staticDependency = new Map();
11753
12839
  _pathSet = new Set();
@@ -11755,6 +12841,7 @@ class State extends HTMLElementBase {
11755
12841
  _rootNode = null;
11756
12842
  _boundComponent = null;
11757
12843
  _boundComponentStateProp = null;
12844
+ _hasMappedComponentState = false;
11758
12845
  _bindableEventMap = {};
11759
12846
  _commandTokenNames = new Set();
11760
12847
  _eventTokenNames = new Set();
@@ -11774,8 +12861,9 @@ class State extends HTMLElementBase {
11774
12861
  this._initializePromise = new Promise((resolve) => {
11775
12862
  this._resolveInitialize = resolve;
11776
12863
  });
11777
- this._connectedCallbackPromise = new Promise((resolve) => {
12864
+ this._connectedCallbackPromise = new Promise((resolve, reject) => {
11778
12865
  this._resolveConnectedCallback = resolve;
12866
+ this._rejectConnectedCallback = reject;
11779
12867
  });
11780
12868
  this._loadingPromise = new Promise((resolve) => {
11781
12869
  this._resolveLoading = resolve;
@@ -11825,6 +12913,9 @@ class State extends HTMLElementBase {
11825
12913
  clearStreamNamespace(this);
11826
12914
  clearStreamRegistry(this);
11827
12915
  processStreamsDeclaration(this, value);
12916
+ // $listKeys: 宣言が無ければ null のままで、setByAddress のキー突合経路には
12917
+ // 一切入らない(docs/state-list-key-design.md §7-1)。再 set で必ず置き換える。
12918
+ this._listKeys = processListKeysDeclaration(value);
11828
12919
  // 接続中の再 set(S13)は新宣言で即再起動する。
11829
12920
  // 初回(_initialize 中)は _initialized が false なのでここでは起動されず、
11830
12921
  // connectedCallback 側の startStreams($connectedCallback 完了後)が担う。
@@ -11935,6 +13026,19 @@ class State extends HTMLElementBase {
11935
13026
  if (!(parentNode instanceof ShadowRoot) && !this.hasAttribute("name")) {
11936
13027
  raiseError(`"bind-component" in Light DOM requires a "name" attribute to avoid namespace conflicts with the parent scope.`);
11937
13028
  }
13029
+ // bind-component はコンポーネント側の state プロパティを唯一のソースにする。
13030
+ // state / src / json / inner <script> と併記すると、この後の _initialize が
13031
+ // そちらを採用して _setStatePromise を await しないため、bindWebComponent が
13032
+ // setInitialState で渡した innerState proxy ごと捨てられ、親↔子マッピングが
13033
+ // 無言で死ぬ。併記は必ず設定ミスなので fail-fast させる
13034
+ // (docs/architecture-hardening/15-state-component-mechanism-consistency.md §2.6)。
13035
+ const conflicting = ["state", "src", "json"].filter((name) => this.hasAttribute(name));
13036
+ if (this.querySelector('script[type="module"]') !== null) {
13037
+ conflicting.push('<script type="module">');
13038
+ }
13039
+ if (conflicting.length > 0) {
13040
+ raiseError(`"bind-component" cannot be combined with ${conflicting.join(", ")}. The component's "${this.getAttribute("bind-component")}" property is the only state source.`);
13041
+ }
11938
13042
  const boundComponentStateProp = this.getAttribute("bind-component");
11939
13043
  await customElements.whenDefined(customTagName.toLowerCase());
11940
13044
  // data-wcs属性がある場合は、上位の状態によりbinding情報の設定が完了するまで待機する
@@ -11953,6 +13057,34 @@ class State extends HTMLElementBase {
11953
13057
  bindWebComponent(this, this._boundComponent, this._boundComponentStateProp, state);
11954
13058
  }
11955
13059
  }
13060
+ /**
13061
+ * mapped な `bind-component` が切断 → 再接続したときに、束ねているパスを読み直させる(§1.9)。
13062
+ *
13063
+ * リスト行の content は再利用されるので、行が作り直されると子はこの経路を通る
13064
+ * (`_initialized` が真なので `_initializeBindWebComponent` / `_initialize` は走らず、
13065
+ * 子のバインディングは張り直されない)。切断中に親で起きた変更の通知は
13066
+ * `applyChangeToWebComponent` が切断済みを理由に落としているため、ここで読み直さないと
13067
+ * 子のビューだけが古い値のまま取り残される。何が変わったかは分からないので、
13068
+ * プライマリ規則の粒度で丸ごと読み直す。
13069
+ *
13070
+ * 読み直しの前に派生規則の memo を捨てる。派生規則の購読者(親スコープに立つ
13071
+ * バインディング)は切断で teardown されており、memo が残っていると導出が二度と
13072
+ * 走らないため購読者も張り直されない = 以後この子だけがサブパスの書き込みを
13073
+ * 受け取れなくなる。捨てておけば、直後の読み直しで導出と購読者登録が走る。
13074
+ */
13075
+ _reloadMappedPathsAfterReconnect() {
13076
+ if (!this._hasMappedComponentState || this._boundComponent === null) {
13077
+ return;
13078
+ }
13079
+ // mapped = プライマリ規則が 1 件以上あることと同義(bindWebComponent の分岐)
13080
+ const innerPaths = getPrimaryInnerPaths(this._boundComponent);
13081
+ resetDerivedMappingRules(this._boundComponent);
13082
+ this.createState("readonly", (state) => {
13083
+ for (const path of innerPaths) {
13084
+ state.$postUpdate(path);
13085
+ }
13086
+ });
13087
+ }
11956
13088
  async _callStateConnectedCallback() {
11957
13089
  await this.createStateAsync("writable", async (state) => {
11958
13090
  // stateに"$connectedCallback"があるか確認し、connectedCallbackAPIを呼び出す
@@ -12013,6 +13145,13 @@ class State extends HTMLElementBase {
12013
13145
  const parentNode = this.parentNode;
12014
13146
  if (parentNode instanceof ShadowRoot &&
12015
13147
  parentNode.host.hasAttribute(DCC_DEFINITION_ATTRIBUTE)) {
13148
+ // DCC と bind-component は排他。DCC の state はテンプレートに属し、
13149
+ // インスタンスごとにロードされるので、定義時点のホストのプロパティを
13150
+ // ソースにする bind-component とは両立しない。従来はこの return で
13151
+ // 無言に無視していた(docs/architecture-hardening/15 §3.1)。
13152
+ if (this.hasAttribute("bind-component")) {
13153
+ raiseError(`"bind-component" cannot be used inside a [${DCC_DEFINITION_ATTRIBUTE}] host. DCC state comes from the template, not from a component property.`);
13154
+ }
12016
13155
  await this._initializeDCC(parentNode.host, parentNode);
12017
13156
  return;
12018
13157
  }
@@ -12026,6 +13165,7 @@ class State extends HTMLElementBase {
12026
13165
  // createState が rootNode 経由でこの要素を解決できるようにするために必要
12027
13166
  // ($connectedCallback の再実行と $streams の initial からの再起動が依存する、設計書 §2-3)。
12028
13167
  setStateElementByName(this._rootNode, this._name, this);
13168
+ this._reloadMappedPathsAfterReconnect();
12029
13169
  }
12030
13170
  // enable-ssr (クライアント側): SSR で $connectedCallback 済みなのでスキップ
12031
13171
  // inSsr() (サーバー側): レンダリング中なので実行する
@@ -12034,14 +13174,24 @@ class State extends HTMLElementBase {
12034
13174
  }
12035
13175
  // サーバーモード + enable-ssr: バインディング完了後に <wcs-ssr> を生成
12036
13176
  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);
13177
+ try {
13178
+ await getBindingsReady(this.rootNode);
13179
+ const name = this.getAttribute('name') || 'default';
13180
+ const stateData = Ssr.extractStateData(this);
13181
+ const ssrEl = document.createElement(config.tagNames.ssr);
13182
+ ssrEl.setAttribute('name', name);
13183
+ ssrEl.setAttribute('version', VERSION);
13184
+ Ssr.buildContent(ssrEl, stateData);
13185
+ this.parentNode?.insertBefore(ssrEl, this);
13186
+ }
13187
+ catch (error) {
13188
+ // reject を配管しないと _connectedCallbackPromise が永久に未解決になり、
13189
+ // renderToString が mutex を握ったまま connectedCallbackPromise 待ちで
13190
+ // 無言ハングする。getBindingsReady の reject 化(設計書 §8.2)を
13191
+ // SSR の消費者(render.ts)まで届けるための対。
13192
+ this._rejectConnectedCallback?.(error);
13193
+ throw error;
13194
+ }
12045
13195
  }
12046
13196
  // $streams の eager 起動($connectedCallback 完了後、設計書 §2-3)。
12047
13197
  // inSsr() 時は起動しない(SSR 出力には initial が乗る、§7-1)。
@@ -12092,6 +13242,9 @@ class State extends HTMLElementBase {
12092
13242
  }
12093
13243
  }
12094
13244
  }
13245
+ get initialized() {
13246
+ return this._initialized;
13247
+ }
12095
13248
  get initializePromise() {
12096
13249
  return this._initializePromise;
12097
13250
  }
@@ -12101,6 +13254,9 @@ class State extends HTMLElementBase {
12101
13254
  get listPaths() {
12102
13255
  return this._listPaths;
12103
13256
  }
13257
+ get listKeys() {
13258
+ return this._listKeys;
13259
+ }
12104
13260
  get elementPaths() {
12105
13261
  return this._elementPaths;
12106
13262
  }
@@ -12128,9 +13284,29 @@ class State extends HTMLElementBase {
12128
13284
  }
12129
13285
  return this._rootNode;
12130
13286
  }
13287
+ /**
13288
+ * `rootNode` を保持しているか = `createState` を呼んでよいか(§1.9)。
13289
+ * disconnect で落ち、connect の冒頭で復活する。
13290
+ */
13291
+ get hasRootNode() {
13292
+ return this._rootNode !== null;
13293
+ }
12131
13294
  get boundComponentStateProp() {
12132
13295
  return this._boundComponentStateProp;
12133
13296
  }
13297
+ get boundComponent() {
13298
+ return this._boundComponent;
13299
+ }
13300
+ get hasMappedComponentState() {
13301
+ return this._hasMappedComponentState;
13302
+ }
13303
+ /**
13304
+ * この state の実体が innerState proxy であることを記録する。唯一の呼び手は
13305
+ * `bindWebComponent` の mapped 分岐(§1.8)。
13306
+ */
13307
+ markComponentStateMapped() {
13308
+ this._hasMappedComponentState = true;
13309
+ }
12134
13310
  get bindableEventMap() {
12135
13311
  return this._bindableEventMap;
12136
13312
  }
@@ -12187,8 +13363,15 @@ class State extends HTMLElementBase {
12187
13363
  }
12188
13364
  setPathInfo(path, bindingType) {
12189
13365
  if (bindingType === "for") {
13366
+ const isNewListPath = !this._listPaths.has(path);
12190
13367
  this._listPaths.add(path);
12191
13368
  this._elementPaths.add(path + '.' + WILDCARD);
13369
+ // mapped な bind-component の子が回している for は、配列の実体を親スコープが
13370
+ // 持っている。親の依存 walk / swap 判定はどちらも「その state 要素の」
13371
+ // listPaths・elementPaths を見るので、マップ先のパスにも同じ宣言を届ける(§1.8)。
13372
+ if (isNewListPath && this._hasMappedComponentState) {
13373
+ propagateListPathToOuterState(this, path);
13374
+ }
12192
13375
  }
12193
13376
  if (!this._pathSet.has(path)) {
12194
13377
  const pathInfo = getPathInfo(path);
@@ -12465,11 +13648,13 @@ function getWcsManifest() {
12465
13648
  ],
12466
13649
  reservedStateApi: [
12467
13650
  STATE_BINDABLES_NAME,
13651
+ STATE_COMMANDS_NAME,
12468
13652
  STATE_COMMAND_TOKENS_NAME,
12469
13653
  STATE_COMMAND_NAMESPACE_NAME,
12470
13654
  STATE_EVENT_TOKENS_NAME,
12471
13655
  STATE_ON_NAME,
12472
13656
  STATE_STREAMS_NAME,
13657
+ STATE_LIST_KEYS_NAME,
12473
13658
  STATE_STREAM_STATUS_NAMESPACE_NAME,
12474
13659
  STATE_STREAM_ERROR_NAMESPACE_NAME,
12475
13660
  ],