@wcstack/state 1.21.3 → 1.21.5

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
  }
@@ -4825,14 +5049,6 @@ function isPhysicallyAfter(lastNode, firstNode) {
4825
5049
  return (position & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
4826
5050
  && (position & Node.DOCUMENT_POSITION_DISCONNECTED) === 0;
4827
5051
  }
4828
- function getContent(node, listIndex) {
4829
- let contentByListIndex = contentByListIndexByNode.get(node);
4830
- if (typeof contentByListIndex === 'undefined') {
4831
- return null;
4832
- }
4833
- const content = contentByListIndex.get(listIndex);
4834
- return typeof content === 'undefined' ? null : content;
4835
- }
4836
5052
  function setContent(node, listIndex, content) {
4837
5053
  let contentByListIndex = contentByListIndexByNode.get(node);
4838
5054
  if (typeof contentByListIndex === 'undefined') {
@@ -4856,10 +5072,10 @@ function applyChangeToFor(bindingInfo, context, newValue) {
4856
5072
  const lastValue = getLastListValueByAbsoluteStateAddress(absAddress);
4857
5073
  const diff = createListDiff(listIndex, lastValue, newValue);
4858
5074
  context.newListValueByAbsAddress.set(absAddress, Array.isArray(newValue) ? newValue : []);
4859
- if (Array.isArray(lastValue)
5075
+ const fullDelete = Array.isArray(lastValue)
4860
5076
  && lastValue.length === diff.deleteIndexSet.size
4861
- && diff.deleteIndexSet.size > 0
4862
- && bindingInfo.node.parentNode !== null) {
5077
+ && diff.deleteIndexSet.size > 0;
5078
+ if (fullDelete && bindingInfo.node.parentNode !== null) {
4863
5079
  let isOnlyNode = isOnlyNodeInParentContentByNode.get(bindingInfo.node);
4864
5080
  if (typeof isOnlyNode === 'undefined') {
4865
5081
  const lastNode = lastNodeByNode.get(bindingInfo.node) || bindingInfo.node;
@@ -4872,13 +5088,39 @@ function applyChangeToFor(bindingInfo, context, newValue) {
4872
5088
  parentNode.appendChild(bindingInfo.node);
4873
5089
  }
4874
5090
  }
4875
- for (const deleteIndex of diff.deleteIndexSet) {
4876
- const content = getContent(bindingInfo.node, deleteIndex);
4877
- if (content !== null) {
4878
- deactivateContent(content);
4879
- content.unmount();
4880
- setPooledContent(bindingInfo, content);
4881
- setContent(bindingInfo.node, deleteIndex, null);
5091
+ // 全削除時、プールに収まらない content は再利用されないため、per-binding
5092
+ // teardown(listener 解除・アドレス台帳・loopContext 掃除)を丸ごと省略して
5093
+ // ノードごと GC に任せる(tryDestroy)。プール行きの分だけ従来どおり解体する
5094
+ // (プール行は binding が生存し続けるため address キャッシュのクリアが必須)。
5095
+ // content 台帳の WeakMap ビルトインは V8 プロファイルで本関数の self に計上される
5096
+ // ホットスポット: 外側の node→map 解決はループ外に持ち上げ、fullDelete(旧全行が
5097
+ // deleteIndexSet に載る=台帳の全エントリが消える)では per-index delete を廃して
5098
+ // 台帳ごと 1 回で手放す。
5099
+ let contentMap = contentByListIndexByNode.get(bindingInfo.node);
5100
+ let poolBudget = fullDelete
5101
+ ? maxPooledContents - getPooledContents(bindingInfo).length
5102
+ : Number.POSITIVE_INFINITY;
5103
+ if (typeof contentMap !== 'undefined') {
5104
+ for (const deleteIndex of diff.deleteIndexSet) {
5105
+ const content = contentMap.get(deleteIndex);
5106
+ if (typeof content !== 'undefined') {
5107
+ if (poolBudget <= 0 && content.tryDestroy()) {
5108
+ deleteContentByNode(bindingInfo.node, content);
5109
+ }
5110
+ else {
5111
+ deactivateContent(content);
5112
+ content.unmount();
5113
+ setPooledContent(bindingInfo, content);
5114
+ poolBudget -= 1;
5115
+ }
5116
+ if (!fullDelete) {
5117
+ contentMap.delete(deleteIndex);
5118
+ }
5119
+ }
5120
+ }
5121
+ if (fullDelete) {
5122
+ contentByListIndexByNode.delete(bindingInfo.node);
5123
+ contentMap = undefined;
4882
5124
  }
4883
5125
  }
4884
5126
  let lastNode = bindingInfo.node;
@@ -4899,14 +5141,16 @@ function applyChangeToFor(bindingInfo, context, newValue) {
4899
5141
  }
4900
5142
  const ssrMode = inSsr();
4901
5143
  const uuid = bindingInfo.uuid ?? '';
5144
+ // 追加行ごとの WeakMap 解決を避けるためプール配列も 1 回だけ引く(プールの配列
5145
+ // 実体は setPooledContent が一度作ったら不変なので、delete ループ後の参照で安定)
5146
+ const pooledContents = pooledContentsByNode.get(bindingInfo.node);
4902
5147
  for (const index of diff.newIndexes) {
4903
5148
  let content;
4904
5149
  // add
4905
5150
  if (diff.addIndexSet.has(index)) {
4906
5151
  const stateAddress = createStateAddress(elementPathInfo, index);
4907
5152
  loopContextStack.createLoopContext(stateAddress, (loopContext) => {
4908
- const pooledContents = getPooledContents(bindingInfo);
4909
- content = pooledContents.pop();
5153
+ content = typeof pooledContents !== 'undefined' ? pooledContents.pop() : undefined;
4910
5154
  if (typeof content === 'undefined') {
4911
5155
  content = createContent(bindingInfo);
4912
5156
  }
@@ -4945,7 +5189,8 @@ function applyChangeToFor(bindingInfo, context, newValue) {
4945
5189
  }
4946
5190
  }
4947
5191
  else {
4948
- content = getContent(bindingInfo.node, index);
5192
+ // getContent 相当(undefined→null 正規化は後段の raiseError 判定が null 比較のため維持)
5193
+ content = (typeof contentMap !== 'undefined' ? contentMap.get(index) ?? null : null);
4949
5194
  if (diff.changeIndexSet.has(index)) {
4950
5195
  // change
4951
5196
  const indexBindings = getIndexBindingsByContent(content);
@@ -4969,7 +5214,11 @@ function applyChangeToFor(bindingInfo, context, newValue) {
4969
5214
  }
4970
5215
  }
4971
5216
  lastNode = content.lastNode || lastNode;
4972
- setContent(bindingInfo.node, index, content);
5217
+ if (typeof contentMap === 'undefined') {
5218
+ contentMap = new WeakMap();
5219
+ contentByListIndexByNode.set(bindingInfo.node, contentMap);
5220
+ }
5221
+ contentMap.set(index, content);
4973
5222
  }
4974
5223
  lastNodeByNode.set(bindingInfo.node, lastNode);
4975
5224
  if (fragment !== null) {
@@ -5458,6 +5707,10 @@ const applyChangeByBindingType = {
5458
5707
  };
5459
5708
  const fnByBinding = new WeakMap();
5460
5709
  const deferredSelectBindingByBinding = new WeakMap();
5710
+ // 未 define カスタム要素チェックの確定メモ。customTag が無い、または define 済みを
5711
+ // 一度確認したら以後は不変(define は不可逆)なので apply 毎の getCustomElement /
5712
+ // registry 照会を省略できる。scoped registry を導入する場合はこの不可逆前提を再検討。
5713
+ const definedApplyVerifiedByBinding = new WeakMap();
5461
5714
  function _applyChange(binding, context) {
5462
5715
  const value = getValue(context.state, binding);
5463
5716
  const filteredValue = getFilteredValue(value, binding.outFilters);
@@ -5543,16 +5796,20 @@ function applyChange(binding, context) {
5543
5796
  if (binding.bindingType === "event") {
5544
5797
  return;
5545
5798
  }
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;
5799
+ if (definedApplyVerifiedByBinding.get(binding) !== true) {
5800
+ const customTag = getCustomElement(binding.replaceNode);
5801
+ if (customTag) {
5802
+ if (getCustomElementRegistry()?.get(customTag) === undefined) {
5803
+ // define のカスタム要素へは今は適用できない(accessor 未確立の要素に
5804
+ // 素の own property を書くと upgrade 後に class accessor を隠してしまう)。
5805
+ // whenDefined 後に最新 state 値で再適用する(two-way attach / deferred
5806
+ // spread と対称。docs/state-binding-init-races.md §2)。
5807
+ scheduleDeferredApply(binding, customTag);
5808
+ return;
5809
+ }
5555
5810
  }
5811
+ // customTag 無し or define 済み確定 → 以後この検査を省略(不可逆)
5812
+ definedApplyVerifiedByBinding.set(binding, true);
5556
5813
  }
5557
5814
  // applyChangeFromBindings のグループ化ループが解決済みルートの一致を検証済みの
5558
5815
  // 場合、stateName さえ一致すれば getRootNode の再解決(native 呼び出し)を省略
@@ -5905,8 +6162,24 @@ function getFragmentNodeInfos(fragment) {
5905
6162
  const subscriberNodes = getSubscriberNodes(fragment);
5906
6163
  for (const subscriberNode of subscriberNodes) {
5907
6164
  const parseBindingTextResults = getParseBindTextResults(subscriberNode);
6165
+ let node = subscriberNode;
6166
+ // テンプレート登録時の事前正規化: text 専用の wcs-text コメントは、この時点で
6167
+ // 空 Text に置き換えておく。行 clone は最初から Text を持ち、getBindingInfos が
6168
+ // その Text を replaceNode に使うため、行ごとの createTextNode と start() 時の
6169
+ // replaceChild(コメント→Text 差し替え)が丸ごと不要になる。
6170
+ // 置換は同じ位置なので nodePath は不変。wcs-for/if 等の構造コメントは
6171
+ // アンカーとしてコメントのまま維持する(bindingType で判別)。
6172
+ // 非フラグメント経路(実 DOM 上のコメント)は従来どおり実行時に差し替える。
6173
+ if (subscriberNode.nodeType === Node.COMMENT_NODE
6174
+ && parseBindingTextResults.length === 1
6175
+ && parseBindingTextResults[0].bindingType === "text"
6176
+ && subscriberNode.parentNode !== null) {
6177
+ const textNode = document.createTextNode("");
6178
+ subscriberNode.parentNode.replaceChild(textNode, subscriberNode);
6179
+ node = textNode;
6180
+ }
5908
6181
  fragmnentNodeInfos.push({
5909
- nodePath: getNodePath(subscriberNode),
6182
+ nodePath: getNodePath(node),
5910
6183
  parseBindTextResults: parseBindingTextResults,
5911
6184
  });
5912
6185
  }
@@ -6110,7 +6383,7 @@ async function buildBindings(root) {
6110
6383
  }
6111
6384
  }
6112
6385
 
6113
- var version = "1.21.3";
6386
+ var version = "1.21.5";
6114
6387
  var pkg = {
6115
6388
  version: version};
6116
6389
 
@@ -8590,7 +8863,6 @@ const _cache = new Map();
8590
8863
  class ResolvedAddress {
8591
8864
  path;
8592
8865
  segments;
8593
- paths;
8594
8866
  wildcardCount;
8595
8867
  wildcardType;
8596
8868
  wildcardIndexes;
@@ -8607,10 +8879,8 @@ class ResolvedAddress {
8607
8879
  // Split path into individual segments
8608
8880
  const segments = path.split(".");
8609
8881
  const tmpPatternSegments = segments.slice();
8610
- const paths = [];
8611
8882
  let incompleteCount = 0; // Count of unresolved wildcards (*)
8612
8883
  let completeCount = 0; // Count of resolved wildcards (numeric indexes)
8613
- let lastPath = "";
8614
8884
  let wildcardCount = 0;
8615
8885
  let wildcardType = "none";
8616
8886
  const wildcardIndexes = [];
@@ -8634,10 +8904,6 @@ class ResolvedAddress {
8634
8904
  wildcardCount++;
8635
8905
  }
8636
8906
  }
8637
- // Build cumulative path array
8638
- lastPath += segment;
8639
- paths.push(lastPath);
8640
- lastPath += (i < segment.length - 1 ? "." : "");
8641
8907
  }
8642
8908
  // Generate pattern string with wildcards normalized
8643
8909
  const structuredPath = tmpPatternSegments.join(".");
@@ -8659,7 +8925,6 @@ class ResolvedAddress {
8659
8925
  }
8660
8926
  this.path = path;
8661
8927
  this.segments = segments;
8662
- this.paths = paths;
8663
8928
  this.wildcardCount = wildcardCount;
8664
8929
  this.wildcardType = wildcardType;
8665
8930
  this.wildcardIndexes = wildcardIndexes;
@@ -9002,6 +9267,7 @@ function _walkExpandWildcard(context, currentWildcardIndex, parentListIndex) {
9002
9267
  }
9003
9268
  }
9004
9269
  }
9270
+ const EMPTY_INDEXES = [];
9005
9271
  /**
9006
9272
  * 静的子展開で訪問する listIndex 群を選ぶ。"diff" でも次の場合は全行に倒す:
9007
9273
  * - diff に変化が一切見えない再代入(同一参照および内容同一コピーの再代入。
@@ -9012,26 +9278,60 @@ function _walkExpandWildcard(context, currentWildcardIndex, parentListIndex) {
9012
9278
  */
9013
9279
  function selectExpansionIndexes(context, sourcePath, _lastValue, _newValue, listDiff) {
9014
9280
  if (context.listExpansion === "full") {
9015
- return listDiff.newIndexes;
9281
+ return { fullRows: listDiff.newIndexes, movedRows: null };
9016
9282
  }
9017
9283
  if (context.stateElement.crossRowListPaths?.has(sourcePath)) {
9018
- return listDiff.newIndexes;
9284
+ return { fullRows: listDiff.newIndexes, movedRows: null };
9019
9285
  }
9020
9286
  if (listDiff.addIndexSet.size === 0 && listDiff.changeIndexSet.size === 0) {
9021
9287
  // 追加も移動も無い。削除も無ければ「変化が見えない再代入」= リフレッシュ意図
9022
9288
  if (listDiff.deleteIndexSet.size === 0) {
9023
- return listDiff.newIndexes;
9289
+ return { fullRows: listDiff.newIndexes, movedRows: null };
9024
9290
  }
9025
9291
  // 削除のみ: 残存行は位置も値も不変なので展開しない
9026
- return listDiff.changeIndexSet;
9292
+ return { fullRows: EMPTY_INDEXES, movedRows: null };
9027
9293
  }
9028
- if (listDiff.addIndexSet.size === 0) {
9029
- return listDiff.changeIndexSet;
9294
+ return { fullRows: listDiff.addIndexSet, movedRows: listDiff.changeIndexSet };
9295
+ }
9296
+ const EMPTY_PATH_INFOS = [];
9297
+ /**
9298
+ * 位置だけが変わった行(movedRows)で展開すべきパス群を求める。
9299
+ * `${listPath}.*` の静的 subtree を辿り、$1 等を読んだ実績のある getter
9300
+ * (indexDependentGetterPaths)だけを返す。行の同一性・listIndex は保たれ
9301
+ * index 以外の入力が不変なので、index を読まない getter / 値パスは再評価不要。
9302
+ * 戻り値:
9303
+ * - IPathInfo[](空可): この各パスだけを行の listIndex で展開する
9304
+ * - null: ネストしたワイルドカード配下に index 依存 getter がある
9305
+ * (listIndex の階数が合わず個別展開できない)→ 呼び出し側で行全体展開に倒す
9306
+ */
9307
+ function getMovedRowExpansionPaths(context, wildcardPath, depPathInfo) {
9308
+ const indexGetters = context.stateElement.indexDependentGetterPaths;
9309
+ if (!indexGetters || indexGetters.size === 0) {
9310
+ return EMPTY_PATH_INFOS;
9030
9311
  }
9031
- if (listDiff.changeIndexSet.size === 0) {
9032
- return listDiff.addIndexSet;
9312
+ let result = null;
9313
+ const queue = [wildcardPath];
9314
+ const seen = new Set(queue);
9315
+ for (let i = 0; i < queue.length; i++) {
9316
+ const path = queue[i];
9317
+ if (indexGetters.has(path)) {
9318
+ const pathInfo = getPathInfo(path);
9319
+ if (pathInfo.wildcardCount !== depPathInfo.wildcardCount) {
9320
+ return null;
9321
+ }
9322
+ (result ??= []).push(pathInfo);
9323
+ }
9324
+ const children = context.staticMap.get(path);
9325
+ if (children) {
9326
+ for (const child of children) {
9327
+ if (!seen.has(child)) {
9328
+ seen.add(child);
9329
+ queue.push(child);
9330
+ }
9331
+ }
9332
+ }
9033
9333
  }
9034
- return [...listDiff.addIndexSet, ...listDiff.changeIndexSet];
9334
+ return result ?? EMPTY_PATH_INFOS;
9035
9335
  }
9036
9336
  function _walkDependency(context, startAddress, callback) {
9037
9337
  const stack = [{ address: startAddress, depth: 0 }];
@@ -9065,11 +9365,35 @@ function _walkDependency(context, startAddress, callback) {
9065
9365
  const absAddress = createAbsoluteStateAddress(absPathInfo, address.listIndex);
9066
9366
  const lastValue = getLastListValueByAbsoluteStateAddress(absAddress);
9067
9367
  const listDiff = createListDiff(address.listIndex, lastValue, newValue);
9068
- for (const listIndex of selectExpansionIndexes(context, sourcePath, lastValue, newValue, listDiff)) {
9368
+ const selection = selectExpansionIndexes(context, sourcePath, lastValue, newValue, listDiff);
9369
+ for (const listIndex of selection.fullRows) {
9069
9370
  const depAddress = createStateAddress(depPathInfo, listIndex);
9070
9371
  context.result.add(depAddress);
9071
9372
  nextEntries.push({ address: depAddress, depth: nextDepth });
9072
9373
  }
9374
+ if (selection.movedRows !== null) {
9375
+ const movedPathInfos = getMovedRowExpansionPaths(context, dep, depPathInfo);
9376
+ if (movedPathInfos === null) {
9377
+ // ネスト配下に index 依存 getter: 安全側で行全体を展開(従来挙動)
9378
+ for (const listIndex of selection.movedRows) {
9379
+ const depAddress = createStateAddress(depPathInfo, listIndex);
9380
+ context.result.add(depAddress);
9381
+ nextEntries.push({ address: depAddress, depth: nextDepth });
9382
+ }
9383
+ }
9384
+ else if (movedPathInfos.length > 0) {
9385
+ // 位置のみ変わった行は index 依存 getter のパスだけを展開する
9386
+ for (const listIndex of selection.movedRows) {
9387
+ for (const pathInfo of movedPathInfos) {
9388
+ const depAddress = createStateAddress(pathInfo, listIndex);
9389
+ context.result.add(depAddress);
9390
+ nextEntries.push({ address: depAddress, depth: nextDepth });
9391
+ }
9392
+ }
9393
+ }
9394
+ // movedPathInfos が空: index を読む getter が subtree に無い =
9395
+ // 位置のみ変わった行の値は不変。展開・dirty 化とも不要。
9396
+ }
9073
9397
  }
9074
9398
  else {
9075
9399
  const depAddress = createStateAddress(depPathInfo, address.listIndex);
@@ -9160,6 +9484,15 @@ function _walkDependency(context, startAddress, callback) {
9160
9484
  }
9161
9485
  }
9162
9486
  function walkDependency(stateName, stateElement, startAddress, staticDependency, dynamicDependency, listPathSet, stateProxy, searchType, callback, options) {
9487
+ // 依存ゼロの葉パス(staticMap / dynamicMap にエントリ無し)は context や Set を
9488
+ // 割り当てず、開始アドレスの callback だけで完結する。リスト行の値書き込み
9489
+ // (update ホットパス)は set 毎にここを通る。開始アドレスへの callback は
9490
+ // 従来の walk 先頭と同一で、戻り値(依存アドレス群)も従来どおり空。
9491
+ const startPath = startAddress.pathInfo.path;
9492
+ if (!staticDependency.has(startPath) && !dynamicDependency.has(startPath)) {
9493
+ callback(startAddress);
9494
+ return [];
9495
+ }
9163
9496
  const context = {
9164
9497
  stateElement: stateElement,
9165
9498
  staticMap: staticDependency,
@@ -9192,6 +9525,31 @@ function walkDependency(stateName, stateElement, startAddress, staticDependency,
9192
9525
  * - finallyで必ず更新情報を登録し、再描画や依存解決に利用
9193
9526
  * - getter/setter経由のスコープ切り替えも考慮した設計
9194
9527
  */
9528
+ // Phase 3: 書き込み時点の因果 context を update record に付与する。
9529
+ // binding 経由の書き込みは呼び出し元の dynamic scope から context を引き継ぎ、
9530
+ // binding 外からの API update は新しい transaction を開始する(設計書 §4 規則 1)。
9531
+ // 依存 walk で enqueue される派生アドレスも同じ書き込みの因果に属する。
9532
+ function notifyWrite(address, absAddress, receiver, handler) {
9533
+ const propagationContext = config.enablePropagationContext
9534
+ ? (getCurrentPropagationContext() ?? beginPropagationTransaction(-1))
9535
+ : null;
9536
+ const updater = getUpdater();
9537
+ updater.enqueueAbsoluteAddress(absAddress, propagationContext);
9538
+ // 依存関係のあるキャッシュを無効化(ダーティ)、更新対象として登録
9539
+ walkDependency(handler.stateName, handler.stateElement, address, handler.stateElement.staticDependency, handler.stateElement.dynamicDependency, handler.stateElement.listPaths, receiver, "new", (depAddress) => {
9540
+ // キャッシュを無効化(ダーティ)
9541
+ if (depAddress === address)
9542
+ return;
9543
+ const absDepPathInfo = getAbsolutePathInfo(handler.stateElement, depAddress.pathInfo);
9544
+ const absDepAddress = createAbsoluteStateAddress(absDepPathInfo, depAddress.listIndex);
9545
+ dirtyCacheEntryByAbsoluteStateAddress(absDepAddress);
9546
+ // 更新対象として登録
9547
+ updater.enqueueAbsoluteAddress(absDepAddress, propagationContext);
9548
+ },
9549
+ // リスト置換時は追加行・位置変更行のみ展開する(未変更行の再訪を省く。
9550
+ // $postUpdate の手動リフレッシュは従来通り全行展開のまま)
9551
+ { listExpansion: "diff" });
9552
+ }
9195
9553
  function _setByAddress(target, address, absAddress, value, receiver, handler) {
9196
9554
  try {
9197
9555
  if (address.pathInfo.path in target) {
@@ -9226,29 +9584,7 @@ function _setByAddress(target, address, absAddress, value, receiver, handler) {
9226
9584
  }
9227
9585
  }
9228
9586
  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" });
9587
+ notifyWrite(address, absAddress, receiver, handler);
9252
9588
  }
9253
9589
  }
9254
9590
  function _setByAddressWithSwap(target, address, absAddress, value, receiver, handler) {
@@ -9289,6 +9625,75 @@ function _setByAddressWithSwap(target, address, absAddress, value, receiver, han
9289
9625
  }
9290
9626
  function setByAddress(target, address, value, receiver, handler) {
9291
9627
  const stateElement = handler.stateElement;
9628
+ const path = address.pathInfo.path;
9629
+ // --- fast path: 宣言済み getter/setter でも swap 対象でもない、親を持つ葉パス ---
9630
+ // 従来は same-value guard の値読み・hasByAddress・実書き込みがそれぞれ親チェーンを
9631
+ // 解決していた(キャッシュヒットでも getByAddress 呼び出しの固定費 ×3)。
9632
+ // 親を 1 回だけ解決し、同じ親オブジェクトに対して guard 判定と Reflect.set を行う。
9633
+ // 非オブジェクト親などの例外形は従来経路へ倒し、挙動差を作らない。
9634
+ if (!(path in target) && address.parentAddress !== null && !stateElement.elementPaths.has(path)) {
9635
+ const parentValue = getByAddress(target, address.parentAddress, receiver, handler);
9636
+ if (typeof parentValue === "object" && parentValue !== null) {
9637
+ // ワイルドカード末尾で listIndex が無い不正アドレスは、従来どおり
9638
+ // 書き込み時(enqueue 済みの try 内)に raiseError する → key は undefined のまま持ち回す
9639
+ const lastSegment = address.pathInfo.lastSegment;
9640
+ const key = lastSegment === WILDCARD
9641
+ ? address.listIndex?.index
9642
+ : lastSegment;
9643
+ let devOldValue;
9644
+ let devHasOldValue = false;
9645
+ if (config.sameValueGuard && (value === null || typeof value !== "object")) {
9646
+ // hasByAddress と同じ「初期化済みスロットか」判定(undefined 格納と未初期化を区別)
9647
+ const has = key !== undefined && key in parentValue;
9648
+ const oldValue = key !== undefined ? parentValue[key] : undefined;
9649
+ if (has && Object.is(oldValue, value)) {
9650
+ return true;
9651
+ }
9652
+ devOldValue = oldValue;
9653
+ devHasOldValue = true;
9654
+ }
9655
+ const cacheable = address.pathInfo.wildcardCount > 0 ||
9656
+ stateElement.getterPaths.has(path);
9657
+ const absPathInfo = getAbsolutePathInfo(stateElement, address.pathInfo);
9658
+ const absAddress = createAbsoluteStateAddress(absPathInfo, address.listIndex);
9659
+ if (devtoolsSink !== null) {
9660
+ devtoolsSink({
9661
+ type: "state:write",
9662
+ absoluteAddress: absAddress,
9663
+ value,
9664
+ oldValue: devOldValue,
9665
+ hasOldValue: devHasOldValue,
9666
+ });
9667
+ }
9668
+ try {
9669
+ if (key === undefined) {
9670
+ raiseError(`address.listIndex?.index is undefined path: ${path}`);
9671
+ }
9672
+ return Reflect.set(parentValue, key, value);
9673
+ }
9674
+ finally {
9675
+ notifyWrite(address, absAddress, receiver, handler);
9676
+ if (cacheable) {
9677
+ setCacheEntryByAbsoluteStateAddress(absAddress, {
9678
+ value: value,
9679
+ dirty: false
9680
+ });
9681
+ }
9682
+ // DCC bindable イベントディスパッチ
9683
+ const eventName = stateElement.bindableEventMap[path];
9684
+ if (eventName) {
9685
+ const rootNode = stateElement.rootNode;
9686
+ if (rootNode instanceof ShadowRoot) {
9687
+ rootNode.host.dispatchEvent(new CustomEvent(eventName, {
9688
+ detail: value,
9689
+ bubbles: true,
9690
+ }));
9691
+ }
9692
+ }
9693
+ }
9694
+ }
9695
+ }
9696
+ // --- end fast path ---
9292
9697
  // --- same-value guard (config.sameValueGuard・既定 ON) ---
9293
9698
  // primitive 値かつ Object.is 同値なら、set / enqueue / walkDependency / DOM 適用 /
9294
9699
  // $updatedCallback / DCC イベントを丸ごとスキップ(標準的なリアクティブ no-op)。
@@ -9710,13 +10115,33 @@ async function setLoopContextAsync(handler, loopContext, callback) {
9710
10115
  // `$streamStatus.<name>` / `$streamError.<name>` の dotted パス判定用プレフィックス
9711
10116
  const STREAM_STATUS_PATH_PREFIX = `${STATE_STREAM_STATUS_NAMESPACE_NAME}${DELIMITER}`;
9712
10117
  const STREAM_ERROR_PATH_PREFIX = `${STATE_STREAM_ERROR_NAMESPACE_NAME}${DELIMITER}`;
10118
+ // symbol API のクロージャは handler(= proxy と 1:1、target/receiver 不変)ごとに
10119
+ // 使い回す。drain の getValue が binding ごとに getByAddressSymbol を引くため、
10120
+ // 毎回の新規クロージャ生成が GC 圧・固定費になっていた。
10121
+ const symbolApiCacheByHandler = new WeakMap();
10122
+ function getSymbolApiCache(handler) {
10123
+ let cache = symbolApiCacheByHandler.get(handler);
10124
+ if (typeof cache === "undefined") {
10125
+ cache = new Map();
10126
+ symbolApiCacheByHandler.set(handler, cache);
10127
+ }
10128
+ return cache;
10129
+ }
9713
10130
  function get(target, prop, receiver, handler) {
9714
10131
  const index = INDEX_BY_INDEX_NAME[prop];
9715
10132
  if (typeof index !== "undefined") {
9716
10133
  if (handler.addressStackLength === 0) {
9717
10134
  raiseError(`No active state reference to get list index for "${prop.toString()}".`);
9718
10135
  }
9719
- const listIndex = handler.lastAddressStack?.listIndex;
10136
+ const lastAddress = handler.lastAddressStack;
10137
+ // getter 評価中のインデックス読み取りを記録する。位置だけが変わった行
10138
+ // (listDiff.changeIndexSet)は index 以外の入力が不変なので、walkDependency の
10139
+ // 静的子展開を「インデックスを読んだ getter の subtree」に限定できる。
10140
+ const lastInfo = lastAddress?.pathInfo;
10141
+ if (lastInfo && handler.stateElement?.getterPaths.has(lastInfo.path)) {
10142
+ handler.stateElement.addIndexDependentGetterPath?.(lastInfo.path);
10143
+ }
10144
+ const listIndex = lastAddress?.listIndex;
9720
10145
  return listIndex?.indexes[index] ?? raiseError(`ListIndex not found: ${prop.toString()}`);
9721
10146
  }
9722
10147
  if (typeof prop === "string") {
@@ -9771,49 +10196,67 @@ function get(target, prop, receiver, handler) {
9771
10196
  return getByAddress(target, stateAddress, receiver, handler);
9772
10197
  }
9773
10198
  else if (typeof prop === "symbol") {
10199
+ const cache = getSymbolApiCache(handler);
10200
+ const cached = cache.get(prop);
10201
+ if (typeof cached !== "undefined") {
10202
+ return cached;
10203
+ }
10204
+ let api;
9774
10205
  switch (prop) {
9775
10206
  case setLoopContextAsyncSymbol: {
9776
- return (loopContext, callback = async () => { }) => {
10207
+ api = (loopContext, callback = async () => { }) => {
9777
10208
  return setLoopContextAsync(handler, loopContext, callback);
9778
10209
  };
10210
+ break;
9779
10211
  }
9780
10212
  case setLoopContextSymbol: {
9781
- return (loopContext, callback = () => { }) => {
10213
+ api = (loopContext, callback = () => { }) => {
9782
10214
  return setLoopContext(handler, loopContext, callback);
9783
10215
  };
10216
+ break;
9784
10217
  }
9785
10218
  case getByAddressSymbol: {
9786
- return (address) => {
10219
+ api = (address) => {
9787
10220
  return getByAddress(target, address, receiver, handler);
9788
10221
  };
10222
+ break;
9789
10223
  }
9790
10224
  case hasByAddressSymbol: {
9791
- return (address) => {
10225
+ api = (address) => {
9792
10226
  return hasByAddress(target, address, receiver, handler);
9793
10227
  };
10228
+ break;
9794
10229
  }
9795
10230
  case setByAddressSymbol: {
9796
- return (address, value) => {
10231
+ api = (address, value) => {
9797
10232
  return setByAddress(target, address, value, receiver, handler);
9798
10233
  };
10234
+ break;
9799
10235
  }
9800
10236
  case connectedCallbackSymbol: {
9801
- return () => {
9802
- return connectedCallback(target, prop, receiver);
10237
+ api = () => {
10238
+ return connectedCallback(target, connectedCallbackSymbol, receiver);
9803
10239
  };
10240
+ break;
9804
10241
  }
9805
10242
  case disconnectedCallbackSymbol: {
9806
- return () => {
9807
- return disconnectedCallback(target, prop, receiver);
10243
+ api = () => {
10244
+ return disconnectedCallback(target, disconnectedCallbackSymbol, receiver);
9808
10245
  };
10246
+ break;
9809
10247
  }
9810
10248
  case updatedCallbackSymbol: {
9811
- return (refs) => {
10249
+ api = (refs) => {
9812
10250
  return updatedCallback(target, refs, receiver, handler);
9813
10251
  };
10252
+ break;
10253
+ }
10254
+ default: {
10255
+ return Reflect.get(target, prop, receiver);
9814
10256
  }
9815
10257
  }
9816
- return Reflect.get(target, prop, receiver);
10258
+ cache.set(prop, api);
10259
+ return api;
9817
10260
  }
9818
10261
  }
9819
10262
 
@@ -10352,6 +10795,9 @@ class State extends HTMLElementBase {
10352
10795
  // 他行を読む getter が検出されたリストパス(diff-filter 展開の全行フォールバック対象)。
10353
10796
  // 依存マップ(static/dynamic)と同様に追加のみ・クリアしない(安全側に固定される)。
10354
10797
  _crossRowListPaths = new Set();
10798
+ // $1 等のインデックスを読んだ getter パス(実行時検出)。位置のみ変わった行の
10799
+ // 静的子展開はこの集合の subtree に限定される。追加のみ・クリアしない(安全側)。
10800
+ _indexDependentGetterPaths = new Set();
10355
10801
  _name = 'default';
10356
10802
  _initialized = false;
10357
10803
  _initializePromise;
@@ -10851,6 +11297,12 @@ class State extends HTMLElementBase {
10851
11297
  addCrossRowListPath(path) {
10852
11298
  this._crossRowListPaths.add(path);
10853
11299
  }
11300
+ get indexDependentGetterPaths() {
11301
+ return this._indexDependentGetterPaths;
11302
+ }
11303
+ addIndexDependentGetterPath(path) {
11304
+ this._indexDependentGetterPaths.add(path);
11305
+ }
10854
11306
  bindProperty(prop, desc) {
10855
11307
  Object.defineProperty(this._state, prop, desc);
10856
11308
  if (prop === STATE_UPDATED_CALLBACK_NAME) {