@wcstack/state 1.21.2 → 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,8 +3418,50 @@ 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();
3426
+ // node → その node に関心を持つ session(anchor として binding を覚えている、
3427
+ // または定義待ちタスクを抱えている)。BindingOwner は mutation で増減した
3428
+ // サブツリーを1回だけ走査し、ここに登録された session だけへ per-node 配送する。
3429
+ // 全 session ブロードキャストだと、リスト行の逐次 append などで
3430
+ // 「session 数 × 変異ノード数」の O(n²) ファンアウトになるため、その正本台帳。
3431
+ // 大多数の node は関心 session が1つなので単一値で持ち、2つ目から Set に昇格する。
3432
+ const interestedSessionsByNode = new WeakMap();
3433
+ function addInterestedSession(node, session) {
3434
+ const current = interestedSessionsByNode.get(node);
3435
+ if (typeof current === "undefined") {
3436
+ interestedSessionsByNode.set(node, session);
3437
+ return;
3438
+ }
3439
+ if (current === session)
3440
+ return;
3441
+ if (current instanceof Set) {
3442
+ current.add(session);
3443
+ return;
3444
+ }
3445
+ interestedSessionsByNode.set(node, new Set([current, session]));
3446
+ }
3447
+ function forEachInterestedSession(node, callback) {
3448
+ const current = interestedSessionsByNode.get(node);
3449
+ if (typeof current === "undefined")
3450
+ return;
3451
+ if (current instanceof Set) {
3452
+ for (const session of Array.from(current))
3453
+ callback(session);
3454
+ return;
3455
+ }
3456
+ callback(current);
3457
+ }
3335
3458
  function forEachInclusive(root, callback) {
3336
3459
  callback(root);
3460
+ // 葉ノード(fragment 一括挿入時のテキスト・空セル等が大多数)では
3461
+ // Array.from(childNodes) の空配列アロケーションを避ける。callback が子を
3462
+ // 追加しうるため firstChild は callback 後に判定する(従来と同一意味論)。
3463
+ if (root.firstChild === null)
3464
+ return;
3337
3465
  for (const child of Array.from(root.childNodes)) {
3338
3466
  forEachInclusive(child, callback);
3339
3467
  }
@@ -3350,8 +3478,6 @@ function observableRootFor(node) {
3350
3478
  }
3351
3479
  class BindingOwner {
3352
3480
  root;
3353
- sessionRefs = new Set();
3354
- knownSessions = new WeakSet();
3355
3481
  observer;
3356
3482
  constructor(root) {
3357
3483
  this.root = root;
@@ -3361,12 +3487,6 @@ class BindingOwner {
3361
3487
  : null;
3362
3488
  this.observer?.observe(root, { childList: true, subtree: true });
3363
3489
  }
3364
- add(session) {
3365
- if (this.knownSessions.has(session))
3366
- return;
3367
- this.knownSessions.add(session);
3368
- this.sessionRefs.add(new WeakRef(session));
3369
- }
3370
3490
  handleMutations(mutations) {
3371
3491
  const removed = [];
3372
3492
  const added = [];
@@ -3374,14 +3494,39 @@ class BindingOwner {
3374
3494
  removed.push(...Array.from(mutation.removedNodes));
3375
3495
  added.push(...Array.from(mutation.addedNodes));
3376
3496
  }
3377
- for (const ref of Array.from(this.sessionRefs)) {
3378
- const session = ref.deref();
3379
- if (typeof session === "undefined") {
3380
- this.sessionRefs.delete(ref);
3497
+ // 走査は owner が1回だけ行い、関心 session が居る node だけを配送・contains
3498
+ // 検査へ進める。contains O(木の深さ) なので、関心の無い node で呼ばない。
3499
+ const reconnected = [];
3500
+ for (const subtree of removed) {
3501
+ // framework が unmount した削除サブツリーは binding を明示 dispose 済みなので
3502
+ // observer 側の冗長走査(forEachInclusive で全 node を歩き handleRemovedNode を
3503
+ // 呼ぶ)を丸ごとスキップする。clear/大量 delete のホットスポット短縮。
3504
+ if (consumeObserverSkipOnRemove(subtree))
3381
3505
  continue;
3382
- }
3383
- session.handleMutations(this.root, removed, added);
3506
+ forEachInclusive(subtree, (node) => {
3507
+ forEachInterestedSession(node, (session) => {
3508
+ if (this.root.contains(node))
3509
+ return;
3510
+ session.handleRemovedNode(node);
3511
+ });
3512
+ });
3513
+ }
3514
+ for (const subtree of added) {
3515
+ // framework がマウントしたサブツリーは record が同期 activate 済みで、追加側
3516
+ // 走査の実質の仕事は connect-snapshot 待ちへの配送だけ。待ちがグローバルに
3517
+ // 無ければ丸ごとスキップする(待ちがあればマークだけ消費して従来走査に戻す)。
3518
+ if (consumeObserverSkipOnAdd(subtree) && !hasPendingObservation())
3519
+ continue;
3520
+ forEachInclusive(subtree, (node) => {
3521
+ forEachInterestedSession(node, (session) => {
3522
+ if (!this.root.contains(node))
3523
+ return;
3524
+ session.handleAddedNode(node, reconnected);
3525
+ });
3526
+ });
3384
3527
  }
3528
+ if (reconnected.length > 0)
3529
+ applyChangeFromBindings(reconnected);
3385
3530
  }
3386
3531
  }
3387
3532
  const ownerByRoot = new WeakMap();
@@ -3444,6 +3589,41 @@ class BindingSession {
3444
3589
  }
3445
3590
  return initialized.filter((binding) => this.shouldApplyState(binding));
3446
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
+ }
3447
3627
  shouldApplyState(binding) {
3448
3628
  if (!config.enableDirectionalInitialSync) {
3449
3629
  if (hasInitialSyncModifier(binding))
@@ -3477,6 +3657,7 @@ class BindingSession {
3477
3657
  raiseError(`CustomElementRegistry is unavailable for <${tagName}>.`);
3478
3658
  }
3479
3659
  this.observe(node);
3660
+ addInterestedSession(node, this);
3480
3661
  const task = { node, active: true, cancel: null };
3481
3662
  let tasks = this.deferredByNode.get(node);
3482
3663
  if (typeof tasks === "undefined") {
@@ -3530,31 +3711,50 @@ class BindingSession {
3530
3711
  this.deferredByNode.get(task.node)?.delete(task);
3531
3712
  }
3532
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
+ }
3533
3743
  observe(node) {
3534
3744
  const root = observableRootFor(node);
3535
3745
  if (root === null)
3536
3746
  return;
3537
- getBindingOwner(root).add(this);
3747
+ // owner(root ごとの MutationObserver)の存在だけ保証する。session の配送先
3748
+ // 登録は node 単位(interestedSessionsByNode)で行い、owner は session を
3749
+ // 直接は保持しない。
3750
+ getBindingOwner(root);
3538
3751
  }
3539
3752
  handleMutations(root, removed, added) {
3540
3753
  for (const subtree of removed) {
3541
3754
  forEachInclusive(subtree, (node) => {
3542
3755
  if (root.contains(node))
3543
3756
  return;
3544
- const known = this.knownBindingsByNode.get(node);
3545
- if (typeof known !== "undefined") {
3546
- for (const binding of known.values())
3547
- this.disposeBinding(binding);
3548
- }
3549
- const tasks = this.deferredByNode.get(node);
3550
- if (typeof tasks !== "undefined") {
3551
- for (const task of Array.from(tasks)) {
3552
- task.active = false;
3553
- task.cancel?.();
3554
- tasks.delete(task);
3555
- this.deferred.delete(task);
3556
- }
3557
- }
3757
+ this.handleRemovedNode(node);
3558
3758
  });
3559
3759
  }
3560
3760
  const reconnected = [];
@@ -3562,42 +3762,68 @@ class BindingSession {
3562
3762
  forEachInclusive(subtree, (node) => {
3563
3763
  if (!root.contains(node))
3564
3764
  return;
3565
- const known = this.knownBindingsByNode.get(node);
3566
- if (typeof known === "undefined")
3567
- return;
3568
- for (const binding of known.values()) {
3569
- const record = recordByBinding.get(binding);
3570
- if (record?.phase === "active") {
3571
- this.settleConnectedSnapshot(record);
3572
- continue;
3573
- }
3574
- if (record?.phase !== "disposed")
3575
- continue;
3576
- const options = this.optionsByBinding.get(binding);
3577
- if (typeof options === "undefined")
3578
- continue;
3579
- try {
3580
- this.start(binding, options);
3581
- if (options.applyOnReconnect && this.shouldApplyState(binding))
3582
- reconnected.push(binding);
3583
- }
3584
- catch {
3585
- // Mutation delivery cannot surface initialization errors to a caller.
3586
- }
3587
- }
3765
+ this.handleAddedNode(node, reconnected);
3588
3766
  });
3589
3767
  }
3590
3768
  if (reconnected.length > 0)
3591
3769
  applyChangeFromBindings(reconnected);
3592
3770
  }
3771
+ handleRemovedNode(node) {
3772
+ const known = this.knownBindingsByNode.get(node);
3773
+ if (typeof known !== "undefined") {
3774
+ for (const binding of known.values())
3775
+ this.disposeBinding(binding);
3776
+ }
3777
+ const tasks = this.deferredByNode.get(node);
3778
+ if (typeof tasks !== "undefined") {
3779
+ for (const task of Array.from(tasks)) {
3780
+ task.active = false;
3781
+ task.cancel?.();
3782
+ tasks.delete(task);
3783
+ this.deferred.delete(task);
3784
+ }
3785
+ }
3786
+ }
3787
+ handleAddedNode(node, reconnected) {
3788
+ const known = this.knownBindingsByNode.get(node);
3789
+ if (typeof known === "undefined")
3790
+ return;
3791
+ for (const binding of known.values()) {
3792
+ const record = recordByBinding.get(binding);
3793
+ if (record?.phase === "active") {
3794
+ this.settleConnectedSnapshot(record);
3795
+ continue;
3796
+ }
3797
+ if (record?.phase !== "disposed")
3798
+ continue;
3799
+ const options = this.optionsByBinding.get(binding);
3800
+ if (typeof options === "undefined")
3801
+ continue;
3802
+ try {
3803
+ this.start(binding, options);
3804
+ if (options.applyOnReconnect && this.shouldApplyState(binding))
3805
+ reconnected.push(binding);
3806
+ }
3807
+ catch {
3808
+ // Mutation delivery cannot surface initialization errors to a caller.
3809
+ }
3810
+ }
3811
+ }
3593
3812
  remember(binding, options) {
3594
3813
  const anchor = binding.replaceNode;
3814
+ // detached fragment 上でも登録しておく(node 単位の台帳なので root 非依存)。
3815
+ // fragment 一括マウントで後から接続された行にも mutation 配送が届くようにする。
3816
+ addInterestedSession(anchor, this);
3595
3817
  let known = this.knownBindingsByNode.get(anchor);
3596
3818
  if (typeof known === "undefined") {
3597
3819
  known = new Map();
3598
3820
  this.knownBindingsByNode.set(anchor, known);
3599
3821
  }
3600
- 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
+ }
3601
3827
  const remembered = known.get(key);
3602
3828
  if (typeof remembered !== "undefined") {
3603
3829
  const rememberedOptions = this.optionsByBinding.get(remembered);
@@ -3759,6 +3985,8 @@ class BindingSession {
3759
3985
  && record.info.node instanceof HTMLElement
3760
3986
  && !record.info.node.isConnected) {
3761
3987
  record.observationPending = true;
3988
+ // 待ちが 1 件でもある間は追加側 observer スキップを無効化する
3989
+ incrementPendingObservation();
3762
3990
  return;
3763
3991
  }
3764
3992
  this.readProducerSnapshot(record, policy.syncOn === "call");
@@ -3779,7 +4007,10 @@ class BindingSession {
3779
4007
  return;
3780
4008
  const sequence = record.eventSequence;
3781
4009
  const value = target[name];
3782
- record.observationPending = false;
4010
+ if (record.observationPending) {
4011
+ record.observationPending = false;
4012
+ decrementPendingObservation();
4013
+ }
3783
4014
  if (eventWins && record.eventSequence !== sequence)
3784
4015
  return;
3785
4016
  record.hasProducerValue = true;
@@ -3843,6 +4074,12 @@ class BindingSession {
3843
4074
  this.records.delete(record);
3844
4075
  }
3845
4076
  runTeardowns(record) {
4077
+ // runTeardowns は record の終端(disposed / failed)でのみ呼ばれる。未消化の
4078
+ // connect-snapshot 待ちが残っていれば必ずカウンタを戻す(スキップ再有効化)。
4079
+ if (record.observationPending) {
4080
+ record.observationPending = false;
4081
+ decrementPendingObservation();
4082
+ }
3846
4083
  const teardowns = Array.from(record.teardowns).reverse();
3847
4084
  record.teardowns.clear();
3848
4085
  for (const teardown of teardowns) {
@@ -4532,11 +4769,9 @@ function activateContent(content, loopContext, context) {
4532
4769
  const bindings = getBindingsByContent(content);
4533
4770
  const session = getBindingSessionByContent(content);
4534
4771
  if (session !== null) {
4535
- session.initialize(bindings, {
4536
- registerAddress: true,
4537
- registerPathInfo: false,
4538
- applyOnReconnect: false,
4539
- });
4772
+ // createContent 側の initialize で remember 済みの同一 binding 配列なので、
4773
+ // remember を再実行しない専用パスで活性化する(リスト行生成のホットパス)
4774
+ session.activate(bindings);
4540
4775
  }
4541
4776
  for (const binding of bindings) {
4542
4777
  if (session === null) {
@@ -4623,6 +4858,10 @@ class Content {
4623
4858
  }
4624
4859
  appendTo(targetNode) {
4625
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);
4626
4865
  targetNode.appendChild(node);
4627
4866
  }
4628
4867
  this._mounted = true;
@@ -4632,14 +4871,51 @@ class Content {
4632
4871
  const nextSibling = targetNode.nextSibling;
4633
4872
  if (parentNode) {
4634
4873
  for (const node of this._childNodeArray) {
4874
+ markObserverSkipOnAdd(node);
4635
4875
  parentNode.insertBefore(node, nextSibling);
4636
4876
  }
4637
4877
  }
4638
4878
  this._mounted = true;
4639
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
+ }
4640
4910
  unmount() {
4641
4911
  getBindingSessionByContent(this)?.dispose();
4642
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);
4643
4919
  if (node.parentNode !== null) {
4644
4920
  node.parentNode.removeChild(node);
4645
4921
  }
@@ -4804,10 +5080,10 @@ function applyChangeToFor(bindingInfo, context, newValue) {
4804
5080
  const lastValue = getLastListValueByAbsoluteStateAddress(absAddress);
4805
5081
  const diff = createListDiff(listIndex, lastValue, newValue);
4806
5082
  context.newListValueByAbsAddress.set(absAddress, Array.isArray(newValue) ? newValue : []);
4807
- if (Array.isArray(lastValue)
5083
+ const fullDelete = Array.isArray(lastValue)
4808
5084
  && lastValue.length === diff.deleteIndexSet.size
4809
- && diff.deleteIndexSet.size > 0
4810
- && bindingInfo.node.parentNode !== null) {
5085
+ && diff.deleteIndexSet.size > 0;
5086
+ if (fullDelete && bindingInfo.node.parentNode !== null) {
4811
5087
  let isOnlyNode = isOnlyNodeInParentContentByNode.get(bindingInfo.node);
4812
5088
  if (typeof isOnlyNode === 'undefined') {
4813
5089
  const lastNode = lastNodeByNode.get(bindingInfo.node) || bindingInfo.node;
@@ -4820,12 +5096,25 @@ function applyChangeToFor(bindingInfo, context, newValue) {
4820
5096
  parentNode.appendChild(bindingInfo.node);
4821
5097
  }
4822
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;
4823
5106
  for (const deleteIndex of diff.deleteIndexSet) {
4824
5107
  const content = getContent(bindingInfo.node, deleteIndex);
4825
5108
  if (content !== null) {
4826
- deactivateContent(content);
4827
- content.unmount();
4828
- 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
+ }
4829
5118
  setContent(bindingInfo.node, deleteIndex, null);
4830
5119
  }
4831
5120
  }
@@ -5406,6 +5695,10 @@ const applyChangeByBindingType = {
5406
5695
  };
5407
5696
  const fnByBinding = new WeakMap();
5408
5697
  const deferredSelectBindingByBinding = new WeakMap();
5698
+ // 未 define カスタム要素チェックの確定メモ。customTag が無い、または define 済みを
5699
+ // 一度確認したら以後は不変(define は不可逆)なので apply 毎の getCustomElement /
5700
+ // registry 照会を省略できる。scoped registry を導入する場合はこの不可逆前提を再検討。
5701
+ const definedApplyVerifiedByBinding = new WeakMap();
5409
5702
  function _applyChange(binding, context) {
5410
5703
  const value = getValue(context.state, binding);
5411
5704
  const filteredValue = getFilteredValue(value, binding.outFilters);
@@ -5491,16 +5784,20 @@ function applyChange(binding, context) {
5491
5784
  if (binding.bindingType === "event") {
5492
5785
  return;
5493
5786
  }
5494
- const customTag = getCustomElement(binding.replaceNode);
5495
- if (customTag) {
5496
- if (getCustomElementRegistry()?.get(customTag) === undefined) {
5497
- // define のカスタム要素へは今は適用できない(accessor 未確立の要素に
5498
- // 素の own property を書くと upgrade 後に class accessor を隠してしまう)。
5499
- // whenDefined 後に最新 state 値で再適用する(two-way attach / deferred
5500
- // spread と対称。docs/state-binding-init-races.md §2)。
5501
- scheduleDeferredApply(binding, customTag);
5502
- 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
+ }
5503
5798
  }
5799
+ // customTag 無し or define 済み確定 → 以後この検査を省略(不可逆)
5800
+ definedApplyVerifiedByBinding.set(binding, true);
5504
5801
  }
5505
5802
  // applyChangeFromBindings のグループ化ループが解決済みルートの一致を検証済みの
5506
5803
  // 場合、stateName さえ一致すれば getRootNode の再解決(native 呼び出し)を省略
@@ -5853,8 +6150,24 @@ function getFragmentNodeInfos(fragment) {
5853
6150
  const subscriberNodes = getSubscriberNodes(fragment);
5854
6151
  for (const subscriberNode of subscriberNodes) {
5855
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
+ }
5856
6169
  fragmnentNodeInfos.push({
5857
- nodePath: getNodePath(subscriberNode),
6170
+ nodePath: getNodePath(node),
5858
6171
  parseBindTextResults: parseBindingTextResults,
5859
6172
  });
5860
6173
  }
@@ -6058,7 +6371,7 @@ async function buildBindings(root) {
6058
6371
  }
6059
6372
  }
6060
6373
 
6061
- var version = "1.21.2";
6374
+ var version = "1.21.4";
6062
6375
  var pkg = {
6063
6376
  version: version};
6064
6377
 
@@ -8538,7 +8851,6 @@ const _cache = new Map();
8538
8851
  class ResolvedAddress {
8539
8852
  path;
8540
8853
  segments;
8541
- paths;
8542
8854
  wildcardCount;
8543
8855
  wildcardType;
8544
8856
  wildcardIndexes;
@@ -8555,10 +8867,8 @@ class ResolvedAddress {
8555
8867
  // Split path into individual segments
8556
8868
  const segments = path.split(".");
8557
8869
  const tmpPatternSegments = segments.slice();
8558
- const paths = [];
8559
8870
  let incompleteCount = 0; // Count of unresolved wildcards (*)
8560
8871
  let completeCount = 0; // Count of resolved wildcards (numeric indexes)
8561
- let lastPath = "";
8562
8872
  let wildcardCount = 0;
8563
8873
  let wildcardType = "none";
8564
8874
  const wildcardIndexes = [];
@@ -8582,10 +8892,6 @@ class ResolvedAddress {
8582
8892
  wildcardCount++;
8583
8893
  }
8584
8894
  }
8585
- // Build cumulative path array
8586
- lastPath += segment;
8587
- paths.push(lastPath);
8588
- lastPath += (i < segment.length - 1 ? "." : "");
8589
8895
  }
8590
8896
  // Generate pattern string with wildcards normalized
8591
8897
  const structuredPath = tmpPatternSegments.join(".");
@@ -8607,7 +8913,6 @@ class ResolvedAddress {
8607
8913
  }
8608
8914
  this.path = path;
8609
8915
  this.segments = segments;
8610
- this.paths = paths;
8611
8916
  this.wildcardCount = wildcardCount;
8612
8917
  this.wildcardType = wildcardType;
8613
8918
  this.wildcardIndexes = wildcardIndexes;
@@ -8950,6 +9255,7 @@ function _walkExpandWildcard(context, currentWildcardIndex, parentListIndex) {
8950
9255
  }
8951
9256
  }
8952
9257
  }
9258
+ const EMPTY_INDEXES = [];
8953
9259
  /**
8954
9260
  * 静的子展開で訪問する listIndex 群を選ぶ。"diff" でも次の場合は全行に倒す:
8955
9261
  * - diff に変化が一切見えない再代入(同一参照および内容同一コピーの再代入。
@@ -8960,26 +9266,60 @@ function _walkExpandWildcard(context, currentWildcardIndex, parentListIndex) {
8960
9266
  */
8961
9267
  function selectExpansionIndexes(context, sourcePath, _lastValue, _newValue, listDiff) {
8962
9268
  if (context.listExpansion === "full") {
8963
- return listDiff.newIndexes;
9269
+ return { fullRows: listDiff.newIndexes, movedRows: null };
8964
9270
  }
8965
9271
  if (context.stateElement.crossRowListPaths?.has(sourcePath)) {
8966
- return listDiff.newIndexes;
9272
+ return { fullRows: listDiff.newIndexes, movedRows: null };
8967
9273
  }
8968
9274
  if (listDiff.addIndexSet.size === 0 && listDiff.changeIndexSet.size === 0) {
8969
9275
  // 追加も移動も無い。削除も無ければ「変化が見えない再代入」= リフレッシュ意図
8970
9276
  if (listDiff.deleteIndexSet.size === 0) {
8971
- return listDiff.newIndexes;
9277
+ return { fullRows: listDiff.newIndexes, movedRows: null };
8972
9278
  }
8973
9279
  // 削除のみ: 残存行は位置も値も不変なので展開しない
8974
- return listDiff.changeIndexSet;
9280
+ return { fullRows: EMPTY_INDEXES, movedRows: null };
8975
9281
  }
8976
- if (listDiff.addIndexSet.size === 0) {
8977
- 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;
8978
9299
  }
8979
- if (listDiff.changeIndexSet.size === 0) {
8980
- 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
+ }
8981
9321
  }
8982
- return [...listDiff.addIndexSet, ...listDiff.changeIndexSet];
9322
+ return result ?? EMPTY_PATH_INFOS;
8983
9323
  }
8984
9324
  function _walkDependency(context, startAddress, callback) {
8985
9325
  const stack = [{ address: startAddress, depth: 0 }];
@@ -9013,11 +9353,35 @@ function _walkDependency(context, startAddress, callback) {
9013
9353
  const absAddress = createAbsoluteStateAddress(absPathInfo, address.listIndex);
9014
9354
  const lastValue = getLastListValueByAbsoluteStateAddress(absAddress);
9015
9355
  const listDiff = createListDiff(address.listIndex, lastValue, newValue);
9016
- 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) {
9017
9358
  const depAddress = createStateAddress(depPathInfo, listIndex);
9018
9359
  context.result.add(depAddress);
9019
9360
  nextEntries.push({ address: depAddress, depth: nextDepth });
9020
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
+ }
9021
9385
  }
9022
9386
  else {
9023
9387
  const depAddress = createStateAddress(depPathInfo, address.listIndex);
@@ -9108,6 +9472,15 @@ function _walkDependency(context, startAddress, callback) {
9108
9472
  }
9109
9473
  }
9110
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
+ }
9111
9484
  const context = {
9112
9485
  stateElement: stateElement,
9113
9486
  staticMap: staticDependency,
@@ -9140,6 +9513,31 @@ function walkDependency(stateName, stateElement, startAddress, staticDependency,
9140
9513
  * - finallyで必ず更新情報を登録し、再描画や依存解決に利用
9141
9514
  * - getter/setter経由のスコープ切り替えも考慮した設計
9142
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
+ }
9143
9541
  function _setByAddress(target, address, absAddress, value, receiver, handler) {
9144
9542
  try {
9145
9543
  if (address.pathInfo.path in target) {
@@ -9174,29 +9572,7 @@ function _setByAddress(target, address, absAddress, value, receiver, handler) {
9174
9572
  }
9175
9573
  }
9176
9574
  finally {
9177
- // Phase 3: 書き込み時点の因果 context を update record に付与する。
9178
- // binding 経由の書き込みは呼び出し元の dynamic scope から context を引き継ぎ、
9179
- // binding 外からの API update は新しい transaction を開始する(設計書 §4 規則 1)。
9180
- // 依存 walk で enqueue される派生アドレスも同じ書き込みの因果に属する。
9181
- const propagationContext = config.enablePropagationContext
9182
- ? (getCurrentPropagationContext() ?? beginPropagationTransaction(-1))
9183
- : null;
9184
- const updater = getUpdater();
9185
- updater.enqueueAbsoluteAddress(absAddress, propagationContext);
9186
- // 依存関係のあるキャッシュを無効化(ダーティ)、更新対象として登録
9187
- walkDependency(handler.stateName, handler.stateElement, address, handler.stateElement.staticDependency, handler.stateElement.dynamicDependency, handler.stateElement.listPaths, receiver, "new", (depAddress) => {
9188
- // キャッシュを無効化(ダーティ)
9189
- if (depAddress === address)
9190
- return;
9191
- const absDepPathInfo = getAbsolutePathInfo(handler.stateElement, depAddress.pathInfo);
9192
- const absDepAddress = createAbsoluteStateAddress(absDepPathInfo, depAddress.listIndex);
9193
- dirtyCacheEntryByAbsoluteStateAddress(absDepAddress);
9194
- // 更新対象として登録
9195
- updater.enqueueAbsoluteAddress(absDepAddress, propagationContext);
9196
- },
9197
- // リスト置換時は追加行・位置変更行のみ展開する(未変更行の再訪を省く。
9198
- // $postUpdate の手動リフレッシュは従来通り全行展開のまま)
9199
- { listExpansion: "diff" });
9575
+ notifyWrite(address, absAddress, receiver, handler);
9200
9576
  }
9201
9577
  }
9202
9578
  function _setByAddressWithSwap(target, address, absAddress, value, receiver, handler) {
@@ -9237,6 +9613,75 @@ function _setByAddressWithSwap(target, address, absAddress, value, receiver, han
9237
9613
  }
9238
9614
  function setByAddress(target, address, value, receiver, handler) {
9239
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 ---
9240
9685
  // --- same-value guard (config.sameValueGuard・既定 ON) ---
9241
9686
  // primitive 値かつ Object.is 同値なら、set / enqueue / walkDependency / DOM 適用 /
9242
9687
  // $updatedCallback / DCC イベントを丸ごとスキップ(標準的なリアクティブ no-op)。
@@ -9658,13 +10103,33 @@ async function setLoopContextAsync(handler, loopContext, callback) {
9658
10103
  // `$streamStatus.<name>` / `$streamError.<name>` の dotted パス判定用プレフィックス
9659
10104
  const STREAM_STATUS_PATH_PREFIX = `${STATE_STREAM_STATUS_NAMESPACE_NAME}${DELIMITER}`;
9660
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
+ }
9661
10118
  function get(target, prop, receiver, handler) {
9662
10119
  const index = INDEX_BY_INDEX_NAME[prop];
9663
10120
  if (typeof index !== "undefined") {
9664
10121
  if (handler.addressStackLength === 0) {
9665
10122
  raiseError(`No active state reference to get list index for "${prop.toString()}".`);
9666
10123
  }
9667
- 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;
9668
10133
  return listIndex?.indexes[index] ?? raiseError(`ListIndex not found: ${prop.toString()}`);
9669
10134
  }
9670
10135
  if (typeof prop === "string") {
@@ -9719,49 +10184,67 @@ function get(target, prop, receiver, handler) {
9719
10184
  return getByAddress(target, stateAddress, receiver, handler);
9720
10185
  }
9721
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;
9722
10193
  switch (prop) {
9723
10194
  case setLoopContextAsyncSymbol: {
9724
- return (loopContext, callback = async () => { }) => {
10195
+ api = (loopContext, callback = async () => { }) => {
9725
10196
  return setLoopContextAsync(handler, loopContext, callback);
9726
10197
  };
10198
+ break;
9727
10199
  }
9728
10200
  case setLoopContextSymbol: {
9729
- return (loopContext, callback = () => { }) => {
10201
+ api = (loopContext, callback = () => { }) => {
9730
10202
  return setLoopContext(handler, loopContext, callback);
9731
10203
  };
10204
+ break;
9732
10205
  }
9733
10206
  case getByAddressSymbol: {
9734
- return (address) => {
10207
+ api = (address) => {
9735
10208
  return getByAddress(target, address, receiver, handler);
9736
10209
  };
10210
+ break;
9737
10211
  }
9738
10212
  case hasByAddressSymbol: {
9739
- return (address) => {
10213
+ api = (address) => {
9740
10214
  return hasByAddress(target, address, receiver, handler);
9741
10215
  };
10216
+ break;
9742
10217
  }
9743
10218
  case setByAddressSymbol: {
9744
- return (address, value) => {
10219
+ api = (address, value) => {
9745
10220
  return setByAddress(target, address, value, receiver, handler);
9746
10221
  };
10222
+ break;
9747
10223
  }
9748
10224
  case connectedCallbackSymbol: {
9749
- return () => {
9750
- return connectedCallback(target, prop, receiver);
10225
+ api = () => {
10226
+ return connectedCallback(target, connectedCallbackSymbol, receiver);
9751
10227
  };
10228
+ break;
9752
10229
  }
9753
10230
  case disconnectedCallbackSymbol: {
9754
- return () => {
9755
- return disconnectedCallback(target, prop, receiver);
10231
+ api = () => {
10232
+ return disconnectedCallback(target, disconnectedCallbackSymbol, receiver);
9756
10233
  };
10234
+ break;
9757
10235
  }
9758
10236
  case updatedCallbackSymbol: {
9759
- return (refs) => {
10237
+ api = (refs) => {
9760
10238
  return updatedCallback(target, refs, receiver, handler);
9761
10239
  };
10240
+ break;
10241
+ }
10242
+ default: {
10243
+ return Reflect.get(target, prop, receiver);
9762
10244
  }
9763
10245
  }
9764
- return Reflect.get(target, prop, receiver);
10246
+ cache.set(prop, api);
10247
+ return api;
9765
10248
  }
9766
10249
  }
9767
10250
 
@@ -10300,6 +10783,9 @@ class State extends HTMLElementBase {
10300
10783
  // 他行を読む getter が検出されたリストパス(diff-filter 展開の全行フォールバック対象)。
10301
10784
  // 依存マップ(static/dynamic)と同様に追加のみ・クリアしない(安全側に固定される)。
10302
10785
  _crossRowListPaths = new Set();
10786
+ // $1 等のインデックスを読んだ getter パス(実行時検出)。位置のみ変わった行の
10787
+ // 静的子展開はこの集合の subtree に限定される。追加のみ・クリアしない(安全側)。
10788
+ _indexDependentGetterPaths = new Set();
10303
10789
  _name = 'default';
10304
10790
  _initialized = false;
10305
10791
  _initializePromise;
@@ -10799,6 +11285,12 @@ class State extends HTMLElementBase {
10799
11285
  addCrossRowListPath(path) {
10800
11286
  this._crossRowListPaths.add(path);
10801
11287
  }
11288
+ get indexDependentGetterPaths() {
11289
+ return this._indexDependentGetterPaths;
11290
+ }
11291
+ addIndexDependentGetterPath(path) {
11292
+ this._indexDependentGetterPaths.add(path);
11293
+ }
10802
11294
  bindProperty(prop, desc) {
10803
11295
  Object.defineProperty(this._state, prop, desc);
10804
11296
  if (prop === STATE_UPDATED_CALLBACK_NAME) {