@wcstack/state 1.21.3 → 1.21.4

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
@@ -83,6 +83,9 @@ function setConfig(partialConfig) {
83
83
  }
84
84
 
85
85
  const bindingPromiseByNode = new WeakMap();
86
+ // resolve 済みマーク。エントリ未生成のまま resolve されたノードは、後から
87
+ // wait された時に「生成して即 resolve」で追いつく。
88
+ const resolvedNodes = new WeakSet();
86
89
  let id$1 = 0;
87
90
  function getInitializeBindingPromiseByNode(node) {
88
91
  let bindingPromise = bindingPromiseByNode.get(node) || null;
@@ -99,6 +102,9 @@ function getInitializeBindingPromiseByNode(node) {
99
102
  resolve: resolveFn
100
103
  };
101
104
  bindingPromiseByNode.set(node, bindingPromise);
105
+ if (resolvedNodes.has(node)) {
106
+ bindingPromise.resolve();
107
+ }
102
108
  return bindingPromise;
103
109
  }
104
110
  async function waitInitializeBinding(node) {
@@ -106,8 +112,15 @@ async function waitInitializeBinding(node) {
106
112
  await bindingPromise.promise;
107
113
  }
108
114
  function resolveInitializedBinding(node) {
109
- const bindingPromise = getInitializeBindingPromiseByNode(node);
110
- bindingPromise.resolve();
115
+ // ホットパス: リスト行では全 subscriber ノードがここを通るが、await する消費者
116
+ // (boundComponent / shadowRoot host)はほぼ居ない。既存エントリが無ければ
117
+ // Promise+closure を生成せず resolve 済みマークだけ残す(15 万個級の割り当て削減)。
118
+ const existing = bindingPromiseByNode.get(node);
119
+ if (typeof existing !== "undefined") {
120
+ existing.resolve();
121
+ return;
122
+ }
123
+ resolvedNodes.add(node);
111
124
  }
112
125
 
113
126
  const DELIMITER = '.';
@@ -575,7 +588,13 @@ function getBindingInfos(node, parseBindingTextResults) {
575
588
  });
576
589
  }
577
590
  else {
578
- const replaceNode = document.createTextNode('');
591
+ // フラグメント登録時に事前正規化済みの Text ノードはそのまま replaceNode
592
+ // 使う(node === replaceNode なら replaceToReplaceNode は no-op)。
593
+ // 実 DOM 上の wcs-text コメント(非フラグメント経路)は従来どおり
594
+ // 空 Text を生成して実行時に差し替える。
595
+ const replaceNode = node.nodeType === Node.TEXT_NODE
596
+ ? node
597
+ : document.createTextNode('');
579
598
  bindingInfos.push({
580
599
  ...parseBindingTextResult,
581
600
  node: node,
@@ -3129,6 +3148,63 @@ function detachTwowayEventHandler(binding) {
3129
3148
  }
3130
3149
  }
3131
3150
 
3151
+ // framework 自身が detach し明示的に解体(deactivate/unmount)したノード。
3152
+ // BindingOwner の MutationObserver は削除サブツリー走査でこれらをスキップする。
3153
+ //
3154
+ // 根拠: 削除時の handleRemovedNode は binding を dispose するだけ(DOM 構造変更も
3155
+ // connect-snapshot 依存も無い)で、framework が unmount 経路で既に dispose 済みの
3156
+ // content に対しては純粋な冗長走査(forEachInclusive で削除サブツリー全体を歩く)に
3157
+ // なる。create(追加)経路は two-way の connect-time snapshot を observer に依存する
3158
+ // ため対象外だが、削除は依存が無いため安全に飛ばせる。
3159
+ //
3160
+ // マークは observer が削除を配送した時点で消費(削除)する。マーク〜配送の間隔は
3161
+ // 単一 microtask であり、その間に外部 DOM 変異は割り込めない(framework の drain は
3162
+ // 同期)ため、マークは framework 由来の削除にしか一致しない。
3163
+ const observerSkipNodes = new WeakSet();
3164
+ function markObserverSkipOnRemove(node) {
3165
+ observerSkipNodes.add(node);
3166
+ }
3167
+ // マーク済みなら true を返しつつマークを消費する。未マークなら false。
3168
+ function consumeObserverSkipOnRemove(node) {
3169
+ if (!observerSkipNodes.has(node)) {
3170
+ return false;
3171
+ }
3172
+ observerSkipNodes.delete(node);
3173
+ return true;
3174
+ }
3175
+ // framework 自身がマウント(Content.appendTo / mountAfter)したノード。
3176
+ // 追加サブツリー走査の実質の仕事は connect-snapshot 待ち(observationPending)の
3177
+ // record への配送だけで、record 自体は同期マウント(activateContent → start)で
3178
+ // observer flush より先に active 済み。よって待ちがグローバルに 1 つも無ければ
3179
+ // 追加側走査も冗長であり丸ごとスキップできる(削除側スキップの対称形)。
3180
+ // マーク〜配送が単一 microtask で外部変異が割り込めない前提も削除側と同じ。
3181
+ const observerSkipAddedNodes = new WeakSet();
3182
+ function markObserverSkipOnAdd(node) {
3183
+ observerSkipAddedNodes.add(node);
3184
+ }
3185
+ // マーク済みなら true を返しつつマークを消費する(削除側と同じ one-shot 契約)。
3186
+ function consumeObserverSkipOnAdd(node) {
3187
+ if (!observerSkipAddedNodes.has(node)) {
3188
+ return false;
3189
+ }
3190
+ observerSkipAddedNodes.delete(node);
3191
+ return true;
3192
+ }
3193
+ // connect-snapshot 待ち(two-way sync=connect で未接続のまま activate された record)の
3194
+ // グローバル件数。> 0 の間は追加側スキップを無効化して従来走査に戻す。
3195
+ // increment は settleInitialRecord、decrement は readProducerSnapshot(消化時)と
3196
+ // runTeardowns(未消化のまま終端した record のリーク防止)が担う。
3197
+ let pendingObservationCount = 0;
3198
+ function incrementPendingObservation() {
3199
+ pendingObservationCount++;
3200
+ }
3201
+ function decrementPendingObservation() {
3202
+ pendingObservationCount--;
3203
+ }
3204
+ function hasPendingObservation() {
3205
+ return pendingObservationCount > 0;
3206
+ }
3207
+
3132
3208
  /**
3133
3209
  * Shares one CustomElementRegistry.whenDefined() continuation per registry/tag.
3134
3210
  * Waiters can be removed independently, so a never-defined tag does not retain
@@ -3226,12 +3302,22 @@ function parseSyncOn(value) {
3226
3302
  function hasInitialSyncModifier(binding) {
3227
3303
  return binding.propModifiers.some((modifier) => modifier.includes("="));
3228
3304
  }
3305
+ // 頻出ポリシー(修飾子なしの通常バインディング)の凍結シングルトン。リスト行では
3306
+ // binding ごとに resolveInitialSyncPolicy が走るため、毎回のオブジェクト割り当てを
3307
+ // 避ける(record.initialPolicy は読み取り専用でしか使われない)。
3308
+ const STATE_CALL_POLICY = Object.freeze({ authority: "state", syncOn: "call", observable: false });
3309
+ const NONE_CALL_POLICY = Object.freeze({ authority: "none", syncOn: "call", observable: false });
3310
+ function statePolicy(authority, syncOn) {
3311
+ if (authority === "state" && syncOn === "call")
3312
+ return STATE_CALL_POLICY;
3313
+ return { authority, syncOn, observable: false };
3314
+ }
3229
3315
  function resolveInitialSyncPolicy(binding) {
3230
3316
  if (!config.enableDirectionalInitialSync) {
3231
3317
  if (hasInitialSyncModifier(binding)) {
3232
3318
  raiseError("init=/sync= modifiers require enableDirectionalInitialSync.");
3233
3319
  }
3234
- return { authority: "state", syncOn: "call", observable: false };
3320
+ return STATE_CALL_POLICY;
3235
3321
  }
3236
3322
  const explicitAuthority = parseAuthority(readOption(binding, "init"));
3237
3323
  const syncOn = parseSyncOn(readOption(binding, "sync"));
@@ -3239,7 +3325,7 @@ function resolveInitialSyncPolicy(binding) {
3239
3325
  if (explicitAuthority !== null && explicitAuthority !== "none") {
3240
3326
  raiseError("Event bindings only allow init=none.");
3241
3327
  }
3242
- return { authority: "none", syncOn, observable: false };
3328
+ return syncOn === "call" ? NONE_CALL_POLICY : { authority: "none", syncOn, observable: false };
3243
3329
  }
3244
3330
  // command.<name>: $command.<method> は命令的な command-token 配線。bindingType は
3245
3331
  // "prop" だが propName ("command.<name>") は wcBindable property ではないため、下の
@@ -3247,17 +3333,17 @@ function resolveInitialSyncPolicy(binding) {
3247
3333
  // 持たない配線なので、現行互換の "state" authority を返す(command token は従来通り
3248
3334
  // 初期 apply で配線される)。
3249
3335
  if (binding.propSegments[0] === "command") {
3250
- return { authority: "state", syncOn, observable: false };
3336
+ return statePolicy("state", syncOn);
3251
3337
  }
3252
3338
  if (binding.bindingType !== "prop") {
3253
3339
  if (explicitAuthority !== null && explicitAuthority !== "state" && explicitAuthority !== "none") {
3254
3340
  raiseError(`Binding type "${binding.bindingType}" does not support init=${explicitAuthority}.`);
3255
3341
  }
3256
- return { authority: explicitAuthority ?? "state", syncOn, observable: false };
3342
+ return statePolicy(explicitAuthority ?? "state", syncOn);
3257
3343
  }
3258
3344
  const declaration = readBindableDeclaration(binding.node);
3259
3345
  if (declaration === null) {
3260
- return { authority: explicitAuthority ?? "state", syncOn, observable: false };
3346
+ return statePolicy(explicitAuthority ?? "state", syncOn);
3261
3347
  }
3262
3348
  const hasOutput = declaration.knownProperties.has(binding.propName);
3263
3349
  const hasInput = declaration.declaredInputs.has(binding.propName);
@@ -3332,6 +3418,11 @@ let nextRecordId = 0;
3332
3418
  let nextGeneration = 0;
3333
3419
  const recordByBinding = new WeakMap();
3334
3420
  const sessionByRoot = new WeakMap();
3421
+ // binding の構造キーは不変フィールドのみから決まる。リスト行の初期化では同一 binding に
3422
+ // 対し remember() が2回呼ばれる(createContent 内 initializeBindingsByFragment と
3423
+ // activateContent の registerAddress 目的の initialize)ため、2度目の文字列生成を避けるべく
3424
+ // binding 単位でメモ化する。プロファイル上 bindingKey は create-10k の JS 自己時間で上位。
3425
+ const bindingKeyByBinding = new WeakMap();
3335
3426
  // node → その node に関心を持つ session(anchor として binding を覚えている、
3336
3427
  // または定義待ちタスクを抱えている)。BindingOwner は mutation で増減した
3337
3428
  // サブツリーを1回だけ走査し、ここに登録された session だけへ per-node 配送する。
@@ -3366,6 +3457,11 @@ function forEachInterestedSession(node, callback) {
3366
3457
  }
3367
3458
  function forEachInclusive(root, callback) {
3368
3459
  callback(root);
3460
+ // 葉ノード(fragment 一括挿入時のテキスト・空セル等が大多数)では
3461
+ // Array.from(childNodes) の空配列アロケーションを避ける。callback が子を
3462
+ // 追加しうるため firstChild は callback 後に判定する(従来と同一意味論)。
3463
+ if (root.firstChild === null)
3464
+ return;
3369
3465
  for (const child of Array.from(root.childNodes)) {
3370
3466
  forEachInclusive(child, callback);
3371
3467
  }
@@ -3402,6 +3498,11 @@ class BindingOwner {
3402
3498
  // 検査へ進める。contains は O(木の深さ) なので、関心の無い node で呼ばない。
3403
3499
  const reconnected = [];
3404
3500
  for (const subtree of removed) {
3501
+ // framework が unmount した削除サブツリーは binding を明示 dispose 済みなので
3502
+ // observer 側の冗長走査(forEachInclusive で全 node を歩き handleRemovedNode を
3503
+ // 呼ぶ)を丸ごとスキップする。clear/大量 delete のホットスポット短縮。
3504
+ if (consumeObserverSkipOnRemove(subtree))
3505
+ continue;
3405
3506
  forEachInclusive(subtree, (node) => {
3406
3507
  forEachInterestedSession(node, (session) => {
3407
3508
  if (this.root.contains(node))
@@ -3411,6 +3512,11 @@ class BindingOwner {
3411
3512
  });
3412
3513
  }
3413
3514
  for (const subtree of added) {
3515
+ // framework がマウントしたサブツリーは record が同期 activate 済みで、追加側
3516
+ // 走査の実質の仕事は connect-snapshot 待ちへの配送だけ。待ちがグローバルに
3517
+ // 無ければ丸ごとスキップする(待ちがあればマークだけ消費して従来走査に戻す)。
3518
+ if (consumeObserverSkipOnAdd(subtree) && !hasPendingObservation())
3519
+ continue;
3414
3520
  forEachInclusive(subtree, (node) => {
3415
3521
  forEachInterestedSession(node, (session) => {
3416
3522
  if (!this.root.contains(node))
@@ -3483,6 +3589,41 @@ class BindingSession {
3483
3589
  }
3484
3590
  return initialized.filter((binding) => this.shouldApplyState(binding));
3485
3591
  }
3592
+ /**
3593
+ * activateContent 専用の再活性化パス。createContent 側の initialize で
3594
+ * remember 済みの binding 配列(bindingsByContent がそのまま保持する同一オブジェクト)
3595
+ * にだけ使える前提で、remember の再実行(キー照合・options マージ・興味登録)を省き、
3596
+ * 必要な仕事だけ行う: 初回活性化はアドレス登録+初期同期、pool 再利用(disposed)は
3597
+ * start による再構築、未知の binding は防御的に従来 initialize へ倒す。
3598
+ */
3599
+ activate(bindings) {
3600
+ for (const binding of bindings) {
3601
+ const record = recordByBinding.get(binding);
3602
+ if (typeof record !== "undefined" && record.session === this
3603
+ && record.phase !== "disposed" && record.phase !== "failed") {
3604
+ if (record.address === null) {
3605
+ // 初回活性化(mountAfter 経路では anchor が接続済みのことがあるため、
3606
+ // 従来 initialize と同様に owner の存在をここで保証する)
3607
+ this.observe(record.anchor);
3608
+ record.options.registerAddress = true;
3609
+ this.registerAddress(record);
3610
+ }
3611
+ if (record.phase === "active")
3612
+ this.settleInitialRecord(record);
3613
+ this.settleConnectedSnapshot(record);
3614
+ continue;
3615
+ }
3616
+ const options = this.optionsByBinding.get(binding);
3617
+ if (typeof options === "undefined") {
3618
+ // この session で remember されていない binding(防御): 従来経路
3619
+ this.initialize([binding], { registerAddress: true, registerPathInfo: false, applyOnReconnect: false });
3620
+ continue;
3621
+ }
3622
+ // pool 再利用: record は disposed。活性化要件(アドレス登録)を昇格して再構築
3623
+ options.registerAddress = true;
3624
+ this.start(binding, options);
3625
+ }
3626
+ }
3486
3627
  shouldApplyState(binding) {
3487
3628
  if (!config.enableDirectionalInitialSync) {
3488
3629
  if (hasInitialSyncModifier(binding))
@@ -3570,6 +3711,35 @@ class BindingSession {
3570
3711
  this.deferredByNode.get(task.node)?.delete(task);
3571
3712
  }
3572
3713
  }
3714
+ /**
3715
+ * wholesale destroy(全行クリアで teardown を GC に任せる高速経路)を適用して
3716
+ * よいか。定義待ち(DefinitionCoordinator の waiter / deferred spread タスク)は
3717
+ * 強参照 Map に閉包が残り、connect-snapshot 待ちは pending カウンタが戻らなく
3718
+ * なるため、1 つでもあれば従来経路(teardown 実行)に倒す。
3719
+ */
3720
+ canWholesaleDestroy() {
3721
+ if (this.deferred.size > 0)
3722
+ return false;
3723
+ for (const record of this.records) {
3724
+ if (record.pendingDefinitions > 0 || record.observationPending)
3725
+ return false;
3726
+ }
3727
+ return true;
3728
+ }
3729
+ /**
3730
+ * 全 record を teardown を走らせずに終端化する(canWholesaleDestroy が true の
3731
+ * content 専用)。イベント listener・アドレス台帳・loopContext はノード/binding
3732
+ * もろとも GC で崩壊する(recordByBinding 以下は全て弱参照)。
3733
+ * handlerBindingRegistry のカウンタは減らないが、残るのはキー文字列と数値のみで
3734
+ * 実害はない設計(handlerBindingRegistry.ts の弱参照化コメント参照)。
3735
+ */
3736
+ destroyRecords() {
3737
+ for (const record of this.records) {
3738
+ record.phase = "disposed";
3739
+ record.teardowns.clear();
3740
+ }
3741
+ this.records.clear();
3742
+ }
3573
3743
  observe(node) {
3574
3744
  const root = observableRootFor(node);
3575
3745
  if (root === null)
@@ -3649,7 +3819,11 @@ class BindingSession {
3649
3819
  known = new Map();
3650
3820
  this.knownBindingsByNode.set(anchor, known);
3651
3821
  }
3652
- const key = bindingKey(binding);
3822
+ let key = bindingKeyByBinding.get(binding);
3823
+ if (typeof key === "undefined") {
3824
+ key = bindingKey(binding);
3825
+ bindingKeyByBinding.set(binding, key);
3826
+ }
3653
3827
  const remembered = known.get(key);
3654
3828
  if (typeof remembered !== "undefined") {
3655
3829
  const rememberedOptions = this.optionsByBinding.get(remembered);
@@ -3811,6 +3985,8 @@ class BindingSession {
3811
3985
  && record.info.node instanceof HTMLElement
3812
3986
  && !record.info.node.isConnected) {
3813
3987
  record.observationPending = true;
3988
+ // 待ちが 1 件でもある間は追加側 observer スキップを無効化する
3989
+ incrementPendingObservation();
3814
3990
  return;
3815
3991
  }
3816
3992
  this.readProducerSnapshot(record, policy.syncOn === "call");
@@ -3831,7 +4007,10 @@ class BindingSession {
3831
4007
  return;
3832
4008
  const sequence = record.eventSequence;
3833
4009
  const value = target[name];
3834
- record.observationPending = false;
4010
+ if (record.observationPending) {
4011
+ record.observationPending = false;
4012
+ decrementPendingObservation();
4013
+ }
3835
4014
  if (eventWins && record.eventSequence !== sequence)
3836
4015
  return;
3837
4016
  record.hasProducerValue = true;
@@ -3895,6 +4074,12 @@ class BindingSession {
3895
4074
  this.records.delete(record);
3896
4075
  }
3897
4076
  runTeardowns(record) {
4077
+ // runTeardowns は record の終端(disposed / failed)でのみ呼ばれる。未消化の
4078
+ // connect-snapshot 待ちが残っていれば必ずカウンタを戻す(スキップ再有効化)。
4079
+ if (record.observationPending) {
4080
+ record.observationPending = false;
4081
+ decrementPendingObservation();
4082
+ }
3898
4083
  const teardowns = Array.from(record.teardowns).reverse();
3899
4084
  record.teardowns.clear();
3900
4085
  for (const teardown of teardowns) {
@@ -4584,11 +4769,9 @@ function activateContent(content, loopContext, context) {
4584
4769
  const bindings = getBindingsByContent(content);
4585
4770
  const session = getBindingSessionByContent(content);
4586
4771
  if (session !== null) {
4587
- session.initialize(bindings, {
4588
- registerAddress: true,
4589
- registerPathInfo: false,
4590
- applyOnReconnect: false,
4591
- });
4772
+ // createContent 側の initialize で remember 済みの同一 binding 配列なので、
4773
+ // remember を再実行しない専用パスで活性化する(リスト行生成のホットパス)
4774
+ session.activate(bindings);
4592
4775
  }
4593
4776
  for (const binding of bindings) {
4594
4777
  if (session === null) {
@@ -4675,6 +4858,10 @@ class Content {
4675
4858
  }
4676
4859
  appendTo(targetNode) {
4677
4860
  for (const node of this._childNodeArray) {
4861
+ // framework 起点のマウントを observer に伝える。中間 fragment へ append する
4862
+ // 経路でも、後続の一括 insertBefore(fragment) の mutation record には
4863
+ // この top-level node が addedNodes として現れるため、ここでのマークが届く。
4864
+ markObserverSkipOnAdd(node);
4678
4865
  targetNode.appendChild(node);
4679
4866
  }
4680
4867
  this._mounted = true;
@@ -4684,14 +4871,51 @@ class Content {
4684
4871
  const nextSibling = targetNode.nextSibling;
4685
4872
  if (parentNode) {
4686
4873
  for (const node of this._childNodeArray) {
4874
+ markObserverSkipOnAdd(node);
4687
4875
  parentNode.insertBefore(node, nextSibling);
4688
4876
  }
4689
4877
  }
4690
4878
  this._mounted = true;
4691
4879
  }
4880
+ tryDestroy() {
4881
+ const session = getBindingSessionByContent(this);
4882
+ // session 無し(SSR ハイドレーション産)や、定義待ち・connect-snapshot 待ちを
4883
+ // 抱える content は teardown 省略でリークするため従来経路に倒す。
4884
+ if (session === null || !session.canWholesaleDestroy()) {
4885
+ return false;
4886
+ }
4887
+ session.destroyRecords();
4888
+ for (const node of this._childNodeArray) {
4889
+ // unmount と同じ理由の observer 向け削除マーク(clear の一括削除でも
4890
+ // top-level node が mutation record の root に現れる)
4891
+ markObserverSkipOnRemove(node);
4892
+ if (node.parentNode !== null) {
4893
+ node.parentNode.removeChild(node);
4894
+ }
4895
+ }
4896
+ const bindings = getBindingsByContent(this);
4897
+ for (const binding of bindings) {
4898
+ if (recursiveBindingTypes.has(binding.bindingType)) {
4899
+ const contents = getContentSetByNode(binding.node);
4900
+ for (const content of contents) {
4901
+ if (!content.tryDestroy()) {
4902
+ content.unmount();
4903
+ }
4904
+ }
4905
+ }
4906
+ }
4907
+ this._mounted = false;
4908
+ return true;
4909
+ }
4692
4910
  unmount() {
4693
4911
  getBindingSessionByContent(this)?.dispose();
4694
4912
  for (const node of this._childNodeArray) {
4913
+ // framework 起点の削除であることを observer に伝える。clear の
4914
+ // parentNode.textContent='' 一括削除でも、この top-level node が
4915
+ // 削除サブツリーの root として mutation record に現れるため、ここで
4916
+ // マークしておけば observer の冗長走査をスキップできる。マークは
4917
+ // 同期実行中に立ち、observer は次 microtask で読むので順序は保証される。
4918
+ markObserverSkipOnRemove(node);
4695
4919
  if (node.parentNode !== null) {
4696
4920
  node.parentNode.removeChild(node);
4697
4921
  }
@@ -4856,10 +5080,10 @@ function applyChangeToFor(bindingInfo, context, newValue) {
4856
5080
  const lastValue = getLastListValueByAbsoluteStateAddress(absAddress);
4857
5081
  const diff = createListDiff(listIndex, lastValue, newValue);
4858
5082
  context.newListValueByAbsAddress.set(absAddress, Array.isArray(newValue) ? newValue : []);
4859
- if (Array.isArray(lastValue)
5083
+ const fullDelete = Array.isArray(lastValue)
4860
5084
  && lastValue.length === diff.deleteIndexSet.size
4861
- && diff.deleteIndexSet.size > 0
4862
- && bindingInfo.node.parentNode !== null) {
5085
+ && diff.deleteIndexSet.size > 0;
5086
+ if (fullDelete && bindingInfo.node.parentNode !== null) {
4863
5087
  let isOnlyNode = isOnlyNodeInParentContentByNode.get(bindingInfo.node);
4864
5088
  if (typeof isOnlyNode === 'undefined') {
4865
5089
  const lastNode = lastNodeByNode.get(bindingInfo.node) || bindingInfo.node;
@@ -4872,12 +5096,25 @@ function applyChangeToFor(bindingInfo, context, newValue) {
4872
5096
  parentNode.appendChild(bindingInfo.node);
4873
5097
  }
4874
5098
  }
5099
+ // 全削除時、プールに収まらない content は再利用されないため、per-binding の
5100
+ // teardown(listener 解除・アドレス台帳・loopContext 掃除)を丸ごと省略して
5101
+ // ノードごと GC に任せる(tryDestroy)。プール行きの分だけ従来どおり解体する
5102
+ // (プール行は binding が生存し続けるため address キャッシュのクリアが必須)。
5103
+ let poolBudget = fullDelete
5104
+ ? maxPooledContents - getPooledContents(bindingInfo).length
5105
+ : Number.POSITIVE_INFINITY;
4875
5106
  for (const deleteIndex of diff.deleteIndexSet) {
4876
5107
  const content = getContent(bindingInfo.node, deleteIndex);
4877
5108
  if (content !== null) {
4878
- deactivateContent(content);
4879
- content.unmount();
4880
- setPooledContent(bindingInfo, content);
5109
+ if (poolBudget <= 0 && content.tryDestroy()) {
5110
+ deleteContentByNode(bindingInfo.node, content);
5111
+ }
5112
+ else {
5113
+ deactivateContent(content);
5114
+ content.unmount();
5115
+ setPooledContent(bindingInfo, content);
5116
+ poolBudget -= 1;
5117
+ }
4881
5118
  setContent(bindingInfo.node, deleteIndex, null);
4882
5119
  }
4883
5120
  }
@@ -5458,6 +5695,10 @@ const applyChangeByBindingType = {
5458
5695
  };
5459
5696
  const fnByBinding = new WeakMap();
5460
5697
  const deferredSelectBindingByBinding = new WeakMap();
5698
+ // 未 define カスタム要素チェックの確定メモ。customTag が無い、または define 済みを
5699
+ // 一度確認したら以後は不変(define は不可逆)なので apply 毎の getCustomElement /
5700
+ // registry 照会を省略できる。scoped registry を導入する場合はこの不可逆前提を再検討。
5701
+ const definedApplyVerifiedByBinding = new WeakMap();
5461
5702
  function _applyChange(binding, context) {
5462
5703
  const value = getValue(context.state, binding);
5463
5704
  const filteredValue = getFilteredValue(value, binding.outFilters);
@@ -5543,16 +5784,20 @@ function applyChange(binding, context) {
5543
5784
  if (binding.bindingType === "event") {
5544
5785
  return;
5545
5786
  }
5546
- const customTag = getCustomElement(binding.replaceNode);
5547
- if (customTag) {
5548
- if (getCustomElementRegistry()?.get(customTag) === undefined) {
5549
- // define のカスタム要素へは今は適用できない(accessor 未確立の要素に
5550
- // 素の own property を書くと upgrade 後に class accessor を隠してしまう)。
5551
- // whenDefined 後に最新 state 値で再適用する(two-way attach / deferred
5552
- // spread と対称。docs/state-binding-init-races.md §2)。
5553
- scheduleDeferredApply(binding, customTag);
5554
- return;
5787
+ if (definedApplyVerifiedByBinding.get(binding) !== true) {
5788
+ const customTag = getCustomElement(binding.replaceNode);
5789
+ if (customTag) {
5790
+ if (getCustomElementRegistry()?.get(customTag) === undefined) {
5791
+ // define のカスタム要素へは今は適用できない(accessor 未確立の要素に
5792
+ // 素の own property を書くと upgrade 後に class accessor を隠してしまう)。
5793
+ // whenDefined 後に最新 state 値で再適用する(two-way attach / deferred
5794
+ // spread と対称。docs/state-binding-init-races.md §2)。
5795
+ scheduleDeferredApply(binding, customTag);
5796
+ return;
5797
+ }
5555
5798
  }
5799
+ // customTag 無し or define 済み確定 → 以後この検査を省略(不可逆)
5800
+ definedApplyVerifiedByBinding.set(binding, true);
5556
5801
  }
5557
5802
  // applyChangeFromBindings のグループ化ループが解決済みルートの一致を検証済みの
5558
5803
  // 場合、stateName さえ一致すれば getRootNode の再解決(native 呼び出し)を省略
@@ -5905,8 +6150,24 @@ function getFragmentNodeInfos(fragment) {
5905
6150
  const subscriberNodes = getSubscriberNodes(fragment);
5906
6151
  for (const subscriberNode of subscriberNodes) {
5907
6152
  const parseBindingTextResults = getParseBindTextResults(subscriberNode);
6153
+ let node = subscriberNode;
6154
+ // テンプレート登録時の事前正規化: text 専用の wcs-text コメントは、この時点で
6155
+ // 空 Text に置き換えておく。行 clone は最初から Text を持ち、getBindingInfos が
6156
+ // その Text を replaceNode に使うため、行ごとの createTextNode と start() 時の
6157
+ // replaceChild(コメント→Text 差し替え)が丸ごと不要になる。
6158
+ // 置換は同じ位置なので nodePath は不変。wcs-for/if 等の構造コメントは
6159
+ // アンカーとしてコメントのまま維持する(bindingType で判別)。
6160
+ // 非フラグメント経路(実 DOM 上のコメント)は従来どおり実行時に差し替える。
6161
+ if (subscriberNode.nodeType === Node.COMMENT_NODE
6162
+ && parseBindingTextResults.length === 1
6163
+ && parseBindingTextResults[0].bindingType === "text"
6164
+ && subscriberNode.parentNode !== null) {
6165
+ const textNode = document.createTextNode("");
6166
+ subscriberNode.parentNode.replaceChild(textNode, subscriberNode);
6167
+ node = textNode;
6168
+ }
5908
6169
  fragmnentNodeInfos.push({
5909
- nodePath: getNodePath(subscriberNode),
6170
+ nodePath: getNodePath(node),
5910
6171
  parseBindTextResults: parseBindingTextResults,
5911
6172
  });
5912
6173
  }
@@ -6110,7 +6371,7 @@ async function buildBindings(root) {
6110
6371
  }
6111
6372
  }
6112
6373
 
6113
- var version = "1.21.3";
6374
+ var version = "1.21.4";
6114
6375
  var pkg = {
6115
6376
  version: version};
6116
6377
 
@@ -8590,7 +8851,6 @@ const _cache = new Map();
8590
8851
  class ResolvedAddress {
8591
8852
  path;
8592
8853
  segments;
8593
- paths;
8594
8854
  wildcardCount;
8595
8855
  wildcardType;
8596
8856
  wildcardIndexes;
@@ -8607,10 +8867,8 @@ class ResolvedAddress {
8607
8867
  // Split path into individual segments
8608
8868
  const segments = path.split(".");
8609
8869
  const tmpPatternSegments = segments.slice();
8610
- const paths = [];
8611
8870
  let incompleteCount = 0; // Count of unresolved wildcards (*)
8612
8871
  let completeCount = 0; // Count of resolved wildcards (numeric indexes)
8613
- let lastPath = "";
8614
8872
  let wildcardCount = 0;
8615
8873
  let wildcardType = "none";
8616
8874
  const wildcardIndexes = [];
@@ -8634,10 +8892,6 @@ class ResolvedAddress {
8634
8892
  wildcardCount++;
8635
8893
  }
8636
8894
  }
8637
- // Build cumulative path array
8638
- lastPath += segment;
8639
- paths.push(lastPath);
8640
- lastPath += (i < segment.length - 1 ? "." : "");
8641
8895
  }
8642
8896
  // Generate pattern string with wildcards normalized
8643
8897
  const structuredPath = tmpPatternSegments.join(".");
@@ -8659,7 +8913,6 @@ class ResolvedAddress {
8659
8913
  }
8660
8914
  this.path = path;
8661
8915
  this.segments = segments;
8662
- this.paths = paths;
8663
8916
  this.wildcardCount = wildcardCount;
8664
8917
  this.wildcardType = wildcardType;
8665
8918
  this.wildcardIndexes = wildcardIndexes;
@@ -9002,6 +9255,7 @@ function _walkExpandWildcard(context, currentWildcardIndex, parentListIndex) {
9002
9255
  }
9003
9256
  }
9004
9257
  }
9258
+ const EMPTY_INDEXES = [];
9005
9259
  /**
9006
9260
  * 静的子展開で訪問する listIndex 群を選ぶ。"diff" でも次の場合は全行に倒す:
9007
9261
  * - diff に変化が一切見えない再代入(同一参照および内容同一コピーの再代入。
@@ -9012,26 +9266,60 @@ function _walkExpandWildcard(context, currentWildcardIndex, parentListIndex) {
9012
9266
  */
9013
9267
  function selectExpansionIndexes(context, sourcePath, _lastValue, _newValue, listDiff) {
9014
9268
  if (context.listExpansion === "full") {
9015
- return listDiff.newIndexes;
9269
+ return { fullRows: listDiff.newIndexes, movedRows: null };
9016
9270
  }
9017
9271
  if (context.stateElement.crossRowListPaths?.has(sourcePath)) {
9018
- return listDiff.newIndexes;
9272
+ return { fullRows: listDiff.newIndexes, movedRows: null };
9019
9273
  }
9020
9274
  if (listDiff.addIndexSet.size === 0 && listDiff.changeIndexSet.size === 0) {
9021
9275
  // 追加も移動も無い。削除も無ければ「変化が見えない再代入」= リフレッシュ意図
9022
9276
  if (listDiff.deleteIndexSet.size === 0) {
9023
- return listDiff.newIndexes;
9277
+ return { fullRows: listDiff.newIndexes, movedRows: null };
9024
9278
  }
9025
9279
  // 削除のみ: 残存行は位置も値も不変なので展開しない
9026
- return listDiff.changeIndexSet;
9280
+ return { fullRows: EMPTY_INDEXES, movedRows: null };
9027
9281
  }
9028
- if (listDiff.addIndexSet.size === 0) {
9029
- return listDiff.changeIndexSet;
9282
+ return { fullRows: listDiff.addIndexSet, movedRows: listDiff.changeIndexSet };
9283
+ }
9284
+ const EMPTY_PATH_INFOS = [];
9285
+ /**
9286
+ * 位置だけが変わった行(movedRows)で展開すべきパス群を求める。
9287
+ * `${listPath}.*` の静的 subtree を辿り、$1 等を読んだ実績のある getter
9288
+ * (indexDependentGetterPaths)だけを返す。行の同一性・listIndex は保たれ
9289
+ * index 以外の入力が不変なので、index を読まない getter / 値パスは再評価不要。
9290
+ * 戻り値:
9291
+ * - IPathInfo[](空可): この各パスだけを行の listIndex で展開する
9292
+ * - null: ネストしたワイルドカード配下に index 依存 getter がある
9293
+ * (listIndex の階数が合わず個別展開できない)→ 呼び出し側で行全体展開に倒す
9294
+ */
9295
+ function getMovedRowExpansionPaths(context, wildcardPath, depPathInfo) {
9296
+ const indexGetters = context.stateElement.indexDependentGetterPaths;
9297
+ if (!indexGetters || indexGetters.size === 0) {
9298
+ return EMPTY_PATH_INFOS;
9030
9299
  }
9031
- if (listDiff.changeIndexSet.size === 0) {
9032
- return listDiff.addIndexSet;
9300
+ let result = null;
9301
+ const queue = [wildcardPath];
9302
+ const seen = new Set(queue);
9303
+ for (let i = 0; i < queue.length; i++) {
9304
+ const path = queue[i];
9305
+ if (indexGetters.has(path)) {
9306
+ const pathInfo = getPathInfo(path);
9307
+ if (pathInfo.wildcardCount !== depPathInfo.wildcardCount) {
9308
+ return null;
9309
+ }
9310
+ (result ??= []).push(pathInfo);
9311
+ }
9312
+ const children = context.staticMap.get(path);
9313
+ if (children) {
9314
+ for (const child of children) {
9315
+ if (!seen.has(child)) {
9316
+ seen.add(child);
9317
+ queue.push(child);
9318
+ }
9319
+ }
9320
+ }
9033
9321
  }
9034
- return [...listDiff.addIndexSet, ...listDiff.changeIndexSet];
9322
+ return result ?? EMPTY_PATH_INFOS;
9035
9323
  }
9036
9324
  function _walkDependency(context, startAddress, callback) {
9037
9325
  const stack = [{ address: startAddress, depth: 0 }];
@@ -9065,11 +9353,35 @@ function _walkDependency(context, startAddress, callback) {
9065
9353
  const absAddress = createAbsoluteStateAddress(absPathInfo, address.listIndex);
9066
9354
  const lastValue = getLastListValueByAbsoluteStateAddress(absAddress);
9067
9355
  const listDiff = createListDiff(address.listIndex, lastValue, newValue);
9068
- for (const listIndex of selectExpansionIndexes(context, sourcePath, lastValue, newValue, listDiff)) {
9356
+ const selection = selectExpansionIndexes(context, sourcePath, lastValue, newValue, listDiff);
9357
+ for (const listIndex of selection.fullRows) {
9069
9358
  const depAddress = createStateAddress(depPathInfo, listIndex);
9070
9359
  context.result.add(depAddress);
9071
9360
  nextEntries.push({ address: depAddress, depth: nextDepth });
9072
9361
  }
9362
+ if (selection.movedRows !== null) {
9363
+ const movedPathInfos = getMovedRowExpansionPaths(context, dep, depPathInfo);
9364
+ if (movedPathInfos === null) {
9365
+ // ネスト配下に index 依存 getter: 安全側で行全体を展開(従来挙動)
9366
+ for (const listIndex of selection.movedRows) {
9367
+ const depAddress = createStateAddress(depPathInfo, listIndex);
9368
+ context.result.add(depAddress);
9369
+ nextEntries.push({ address: depAddress, depth: nextDepth });
9370
+ }
9371
+ }
9372
+ else if (movedPathInfos.length > 0) {
9373
+ // 位置のみ変わった行は index 依存 getter のパスだけを展開する
9374
+ for (const listIndex of selection.movedRows) {
9375
+ for (const pathInfo of movedPathInfos) {
9376
+ const depAddress = createStateAddress(pathInfo, listIndex);
9377
+ context.result.add(depAddress);
9378
+ nextEntries.push({ address: depAddress, depth: nextDepth });
9379
+ }
9380
+ }
9381
+ }
9382
+ // movedPathInfos が空: index を読む getter が subtree に無い =
9383
+ // 位置のみ変わった行の値は不変。展開・dirty 化とも不要。
9384
+ }
9073
9385
  }
9074
9386
  else {
9075
9387
  const depAddress = createStateAddress(depPathInfo, address.listIndex);
@@ -9160,6 +9472,15 @@ function _walkDependency(context, startAddress, callback) {
9160
9472
  }
9161
9473
  }
9162
9474
  function walkDependency(stateName, stateElement, startAddress, staticDependency, dynamicDependency, listPathSet, stateProxy, searchType, callback, options) {
9475
+ // 依存ゼロの葉パス(staticMap / dynamicMap にエントリ無し)は context や Set を
9476
+ // 割り当てず、開始アドレスの callback だけで完結する。リスト行の値書き込み
9477
+ // (update ホットパス)は set 毎にここを通る。開始アドレスへの callback は
9478
+ // 従来の walk 先頭と同一で、戻り値(依存アドレス群)も従来どおり空。
9479
+ const startPath = startAddress.pathInfo.path;
9480
+ if (!staticDependency.has(startPath) && !dynamicDependency.has(startPath)) {
9481
+ callback(startAddress);
9482
+ return [];
9483
+ }
9163
9484
  const context = {
9164
9485
  stateElement: stateElement,
9165
9486
  staticMap: staticDependency,
@@ -9192,6 +9513,31 @@ function walkDependency(stateName, stateElement, startAddress, staticDependency,
9192
9513
  * - finallyで必ず更新情報を登録し、再描画や依存解決に利用
9193
9514
  * - getter/setter経由のスコープ切り替えも考慮した設計
9194
9515
  */
9516
+ // Phase 3: 書き込み時点の因果 context を update record に付与する。
9517
+ // binding 経由の書き込みは呼び出し元の dynamic scope から context を引き継ぎ、
9518
+ // binding 外からの API update は新しい transaction を開始する(設計書 §4 規則 1)。
9519
+ // 依存 walk で enqueue される派生アドレスも同じ書き込みの因果に属する。
9520
+ function notifyWrite(address, absAddress, receiver, handler) {
9521
+ const propagationContext = config.enablePropagationContext
9522
+ ? (getCurrentPropagationContext() ?? beginPropagationTransaction(-1))
9523
+ : null;
9524
+ const updater = getUpdater();
9525
+ updater.enqueueAbsoluteAddress(absAddress, propagationContext);
9526
+ // 依存関係のあるキャッシュを無効化(ダーティ)、更新対象として登録
9527
+ walkDependency(handler.stateName, handler.stateElement, address, handler.stateElement.staticDependency, handler.stateElement.dynamicDependency, handler.stateElement.listPaths, receiver, "new", (depAddress) => {
9528
+ // キャッシュを無効化(ダーティ)
9529
+ if (depAddress === address)
9530
+ return;
9531
+ const absDepPathInfo = getAbsolutePathInfo(handler.stateElement, depAddress.pathInfo);
9532
+ const absDepAddress = createAbsoluteStateAddress(absDepPathInfo, depAddress.listIndex);
9533
+ dirtyCacheEntryByAbsoluteStateAddress(absDepAddress);
9534
+ // 更新対象として登録
9535
+ updater.enqueueAbsoluteAddress(absDepAddress, propagationContext);
9536
+ },
9537
+ // リスト置換時は追加行・位置変更行のみ展開する(未変更行の再訪を省く。
9538
+ // $postUpdate の手動リフレッシュは従来通り全行展開のまま)
9539
+ { listExpansion: "diff" });
9540
+ }
9195
9541
  function _setByAddress(target, address, absAddress, value, receiver, handler) {
9196
9542
  try {
9197
9543
  if (address.pathInfo.path in target) {
@@ -9226,29 +9572,7 @@ function _setByAddress(target, address, absAddress, value, receiver, handler) {
9226
9572
  }
9227
9573
  }
9228
9574
  finally {
9229
- // Phase 3: 書き込み時点の因果 context を update record に付与する。
9230
- // binding 経由の書き込みは呼び出し元の dynamic scope から context を引き継ぎ、
9231
- // binding 外からの API update は新しい transaction を開始する(設計書 §4 規則 1)。
9232
- // 依存 walk で enqueue される派生アドレスも同じ書き込みの因果に属する。
9233
- const propagationContext = config.enablePropagationContext
9234
- ? (getCurrentPropagationContext() ?? beginPropagationTransaction(-1))
9235
- : null;
9236
- const updater = getUpdater();
9237
- updater.enqueueAbsoluteAddress(absAddress, propagationContext);
9238
- // 依存関係のあるキャッシュを無効化(ダーティ)、更新対象として登録
9239
- walkDependency(handler.stateName, handler.stateElement, address, handler.stateElement.staticDependency, handler.stateElement.dynamicDependency, handler.stateElement.listPaths, receiver, "new", (depAddress) => {
9240
- // キャッシュを無効化(ダーティ)
9241
- if (depAddress === address)
9242
- return;
9243
- const absDepPathInfo = getAbsolutePathInfo(handler.stateElement, depAddress.pathInfo);
9244
- const absDepAddress = createAbsoluteStateAddress(absDepPathInfo, depAddress.listIndex);
9245
- dirtyCacheEntryByAbsoluteStateAddress(absDepAddress);
9246
- // 更新対象として登録
9247
- updater.enqueueAbsoluteAddress(absDepAddress, propagationContext);
9248
- },
9249
- // リスト置換時は追加行・位置変更行のみ展開する(未変更行の再訪を省く。
9250
- // $postUpdate の手動リフレッシュは従来通り全行展開のまま)
9251
- { listExpansion: "diff" });
9575
+ notifyWrite(address, absAddress, receiver, handler);
9252
9576
  }
9253
9577
  }
9254
9578
  function _setByAddressWithSwap(target, address, absAddress, value, receiver, handler) {
@@ -9289,6 +9613,75 @@ function _setByAddressWithSwap(target, address, absAddress, value, receiver, han
9289
9613
  }
9290
9614
  function setByAddress(target, address, value, receiver, handler) {
9291
9615
  const stateElement = handler.stateElement;
9616
+ const path = address.pathInfo.path;
9617
+ // --- fast path: 宣言済み getter/setter でも swap 対象でもない、親を持つ葉パス ---
9618
+ // 従来は same-value guard の値読み・hasByAddress・実書き込みがそれぞれ親チェーンを
9619
+ // 解決していた(キャッシュヒットでも getByAddress 呼び出しの固定費 ×3)。
9620
+ // 親を 1 回だけ解決し、同じ親オブジェクトに対して guard 判定と Reflect.set を行う。
9621
+ // 非オブジェクト親などの例外形は従来経路へ倒し、挙動差を作らない。
9622
+ if (!(path in target) && address.parentAddress !== null && !stateElement.elementPaths.has(path)) {
9623
+ const parentValue = getByAddress(target, address.parentAddress, receiver, handler);
9624
+ if (typeof parentValue === "object" && parentValue !== null) {
9625
+ // ワイルドカード末尾で listIndex が無い不正アドレスは、従来どおり
9626
+ // 書き込み時(enqueue 済みの try 内)に raiseError する → key は undefined のまま持ち回す
9627
+ const lastSegment = address.pathInfo.lastSegment;
9628
+ const key = lastSegment === WILDCARD
9629
+ ? address.listIndex?.index
9630
+ : lastSegment;
9631
+ let devOldValue;
9632
+ let devHasOldValue = false;
9633
+ if (config.sameValueGuard && (value === null || typeof value !== "object")) {
9634
+ // hasByAddress と同じ「初期化済みスロットか」判定(undefined 格納と未初期化を区別)
9635
+ const has = key !== undefined && key in parentValue;
9636
+ const oldValue = key !== undefined ? parentValue[key] : undefined;
9637
+ if (has && Object.is(oldValue, value)) {
9638
+ return true;
9639
+ }
9640
+ devOldValue = oldValue;
9641
+ devHasOldValue = true;
9642
+ }
9643
+ const cacheable = address.pathInfo.wildcardCount > 0 ||
9644
+ stateElement.getterPaths.has(path);
9645
+ const absPathInfo = getAbsolutePathInfo(stateElement, address.pathInfo);
9646
+ const absAddress = createAbsoluteStateAddress(absPathInfo, address.listIndex);
9647
+ if (devtoolsSink !== null) {
9648
+ devtoolsSink({
9649
+ type: "state:write",
9650
+ absoluteAddress: absAddress,
9651
+ value,
9652
+ oldValue: devOldValue,
9653
+ hasOldValue: devHasOldValue,
9654
+ });
9655
+ }
9656
+ try {
9657
+ if (key === undefined) {
9658
+ raiseError(`address.listIndex?.index is undefined path: ${path}`);
9659
+ }
9660
+ return Reflect.set(parentValue, key, value);
9661
+ }
9662
+ finally {
9663
+ notifyWrite(address, absAddress, receiver, handler);
9664
+ if (cacheable) {
9665
+ setCacheEntryByAbsoluteStateAddress(absAddress, {
9666
+ value: value,
9667
+ dirty: false
9668
+ });
9669
+ }
9670
+ // DCC bindable イベントディスパッチ
9671
+ const eventName = stateElement.bindableEventMap[path];
9672
+ if (eventName) {
9673
+ const rootNode = stateElement.rootNode;
9674
+ if (rootNode instanceof ShadowRoot) {
9675
+ rootNode.host.dispatchEvent(new CustomEvent(eventName, {
9676
+ detail: value,
9677
+ bubbles: true,
9678
+ }));
9679
+ }
9680
+ }
9681
+ }
9682
+ }
9683
+ }
9684
+ // --- end fast path ---
9292
9685
  // --- same-value guard (config.sameValueGuard・既定 ON) ---
9293
9686
  // primitive 値かつ Object.is 同値なら、set / enqueue / walkDependency / DOM 適用 /
9294
9687
  // $updatedCallback / DCC イベントを丸ごとスキップ(標準的なリアクティブ no-op)。
@@ -9710,13 +10103,33 @@ async function setLoopContextAsync(handler, loopContext, callback) {
9710
10103
  // `$streamStatus.<name>` / `$streamError.<name>` の dotted パス判定用プレフィックス
9711
10104
  const STREAM_STATUS_PATH_PREFIX = `${STATE_STREAM_STATUS_NAMESPACE_NAME}${DELIMITER}`;
9712
10105
  const STREAM_ERROR_PATH_PREFIX = `${STATE_STREAM_ERROR_NAMESPACE_NAME}${DELIMITER}`;
10106
+ // symbol API のクロージャは handler(= proxy と 1:1、target/receiver 不変)ごとに
10107
+ // 使い回す。drain の getValue が binding ごとに getByAddressSymbol を引くため、
10108
+ // 毎回の新規クロージャ生成が GC 圧・固定費になっていた。
10109
+ const symbolApiCacheByHandler = new WeakMap();
10110
+ function getSymbolApiCache(handler) {
10111
+ let cache = symbolApiCacheByHandler.get(handler);
10112
+ if (typeof cache === "undefined") {
10113
+ cache = new Map();
10114
+ symbolApiCacheByHandler.set(handler, cache);
10115
+ }
10116
+ return cache;
10117
+ }
9713
10118
  function get(target, prop, receiver, handler) {
9714
10119
  const index = INDEX_BY_INDEX_NAME[prop];
9715
10120
  if (typeof index !== "undefined") {
9716
10121
  if (handler.addressStackLength === 0) {
9717
10122
  raiseError(`No active state reference to get list index for "${prop.toString()}".`);
9718
10123
  }
9719
- const listIndex = handler.lastAddressStack?.listIndex;
10124
+ const lastAddress = handler.lastAddressStack;
10125
+ // getter 評価中のインデックス読み取りを記録する。位置だけが変わった行
10126
+ // (listDiff.changeIndexSet)は index 以外の入力が不変なので、walkDependency の
10127
+ // 静的子展開を「インデックスを読んだ getter の subtree」に限定できる。
10128
+ const lastInfo = lastAddress?.pathInfo;
10129
+ if (lastInfo && handler.stateElement?.getterPaths.has(lastInfo.path)) {
10130
+ handler.stateElement.addIndexDependentGetterPath?.(lastInfo.path);
10131
+ }
10132
+ const listIndex = lastAddress?.listIndex;
9720
10133
  return listIndex?.indexes[index] ?? raiseError(`ListIndex not found: ${prop.toString()}`);
9721
10134
  }
9722
10135
  if (typeof prop === "string") {
@@ -9771,49 +10184,67 @@ function get(target, prop, receiver, handler) {
9771
10184
  return getByAddress(target, stateAddress, receiver, handler);
9772
10185
  }
9773
10186
  else if (typeof prop === "symbol") {
10187
+ const cache = getSymbolApiCache(handler);
10188
+ const cached = cache.get(prop);
10189
+ if (typeof cached !== "undefined") {
10190
+ return cached;
10191
+ }
10192
+ let api;
9774
10193
  switch (prop) {
9775
10194
  case setLoopContextAsyncSymbol: {
9776
- return (loopContext, callback = async () => { }) => {
10195
+ api = (loopContext, callback = async () => { }) => {
9777
10196
  return setLoopContextAsync(handler, loopContext, callback);
9778
10197
  };
10198
+ break;
9779
10199
  }
9780
10200
  case setLoopContextSymbol: {
9781
- return (loopContext, callback = () => { }) => {
10201
+ api = (loopContext, callback = () => { }) => {
9782
10202
  return setLoopContext(handler, loopContext, callback);
9783
10203
  };
10204
+ break;
9784
10205
  }
9785
10206
  case getByAddressSymbol: {
9786
- return (address) => {
10207
+ api = (address) => {
9787
10208
  return getByAddress(target, address, receiver, handler);
9788
10209
  };
10210
+ break;
9789
10211
  }
9790
10212
  case hasByAddressSymbol: {
9791
- return (address) => {
10213
+ api = (address) => {
9792
10214
  return hasByAddress(target, address, receiver, handler);
9793
10215
  };
10216
+ break;
9794
10217
  }
9795
10218
  case setByAddressSymbol: {
9796
- return (address, value) => {
10219
+ api = (address, value) => {
9797
10220
  return setByAddress(target, address, value, receiver, handler);
9798
10221
  };
10222
+ break;
9799
10223
  }
9800
10224
  case connectedCallbackSymbol: {
9801
- return () => {
9802
- return connectedCallback(target, prop, receiver);
10225
+ api = () => {
10226
+ return connectedCallback(target, connectedCallbackSymbol, receiver);
9803
10227
  };
10228
+ break;
9804
10229
  }
9805
10230
  case disconnectedCallbackSymbol: {
9806
- return () => {
9807
- return disconnectedCallback(target, prop, receiver);
10231
+ api = () => {
10232
+ return disconnectedCallback(target, disconnectedCallbackSymbol, receiver);
9808
10233
  };
10234
+ break;
9809
10235
  }
9810
10236
  case updatedCallbackSymbol: {
9811
- return (refs) => {
10237
+ api = (refs) => {
9812
10238
  return updatedCallback(target, refs, receiver, handler);
9813
10239
  };
10240
+ break;
10241
+ }
10242
+ default: {
10243
+ return Reflect.get(target, prop, receiver);
9814
10244
  }
9815
10245
  }
9816
- return Reflect.get(target, prop, receiver);
10246
+ cache.set(prop, api);
10247
+ return api;
9817
10248
  }
9818
10249
  }
9819
10250
 
@@ -10352,6 +10783,9 @@ class State extends HTMLElementBase {
10352
10783
  // 他行を読む getter が検出されたリストパス(diff-filter 展開の全行フォールバック対象)。
10353
10784
  // 依存マップ(static/dynamic)と同様に追加のみ・クリアしない(安全側に固定される)。
10354
10785
  _crossRowListPaths = new Set();
10786
+ // $1 等のインデックスを読んだ getter パス(実行時検出)。位置のみ変わった行の
10787
+ // 静的子展開はこの集合の subtree に限定される。追加のみ・クリアしない(安全側)。
10788
+ _indexDependentGetterPaths = new Set();
10355
10789
  _name = 'default';
10356
10790
  _initialized = false;
10357
10791
  _initializePromise;
@@ -10851,6 +11285,12 @@ class State extends HTMLElementBase {
10851
11285
  addCrossRowListPath(path) {
10852
11286
  this._crossRowListPaths.add(path);
10853
11287
  }
11288
+ get indexDependentGetterPaths() {
11289
+ return this._indexDependentGetterPaths;
11290
+ }
11291
+ addIndexDependentGetterPath(path) {
11292
+ this._indexDependentGetterPaths.add(path);
11293
+ }
10854
11294
  bindProperty(prop, desc) {
10855
11295
  Object.defineProperty(this._state, prop, desc);
10856
11296
  if (prop === STATE_UPDATED_CALLBACK_NAME) {