DOMから除外されたAction要素のイベントリナーキャッシュをクリアする修正

hotwired/stimulus

このPRは、コントローラが残存している状態で data-action を持つ要素が DOM から除去された際に、Dispatcher のキャッシュした EventListener が解放されずメモリリークを起こす問題を解消します。要素が切断されたときだけキャッシュを削除することで、不要な保持を防ぎつつ既存の動作は維持します。

背景

Dispatcher#eventListenerMaps は EventTarget をキーとした強参照 Map で、現在はコントローラ全体が切断されたときのみエントリが削除されます。そのため、コントローラは残りつつ子要素の data-action が削除されると、対応する EventListener とマップエントリがアプリケーションの寿命まで残り、DOM ノードとその参照が解放されません。

実際に Basecamp のページで 70 行ほどのリストが Morph 再描画されるシナリオをプロファイルすると、除去された行のボタンが保持したサブツリーがヒープに残り、再描画ごとに約 135 KB が蓄積されました。リテイナーチェーンは detached <button data-action …>Map (eventListenerMaps)DispatcherApplication の形で確認されています。

技術的な変更

BindingObserver#disconnectAction において、要素の接続状態を判定し bindingDisconnected に第2引数 clearEventListeners を渡すロジックを追加しました。具体的には !action.element.isConnectedtrue のときに clearEventListenerstrue となり、切断済み要素のキャッシュがクリアされます。

@@ -67,7 +67,12 @@ export class BindingObserver implements ValueListObserverDelegate<Action> {
     if (binding) {
       this.bindingsByAction.delete(action)
-      this.delegate.bindingDisconnected(binding)
+      // Clear the dispatcher's cached event listener when the action's element
+      // has left the document. Keeping the cache for connected elements
+      // preserves { once: true } firing history across action attribute
+      // updates, but caching listeners for removed elements would retain the
+      // element (and everything it references) for the application's lifetime.
+      this.delegate.bindingDisconnected(binding, !action.element.isConnected)
     }
   }

Dispatcher#bindingDisconnected は新たに clearEventListeners パラメータを受け取り、true の場合に clearEventListenersForBinding を呼び出すようになりました。clearEventListenersForBinding はマップから該当エントリを削除し、リスナー自体も切断します。

// src/core/dispatcher.ts (抜粋)
bindingDisconnected(binding, clearEventListeners) {
  if (clearEventListeners) this.clearEventListenersForBinding(binding)
  // 既存のロジックはそのまま保持
}

clearEventListenersForBinding(binding) {
  if (!this.hasBindings(binding)) return
  // マップエントリ削除とリスナーの切断処理
}

テストも拡充され、memory_tests.ts に「コントローラは残るが Action 要素だけを削除した場合にキャッシュされたリスナーが減少する」シナリオが追加されました。これにより回帰が防止され、Chrome と Firefox の全テストが 430/430 で成功します。

export default class MemoryTests extends ControllerTestCase() {
  // 既存テスト省略
+  async "test removing an action element while its controller remains clears dangling eventListeners"() {
+    this.assert.equal(this.application.dispatcher.eventListeners.length, 2)
+    await this.remove("button[data-action$='#doLog']")
+    this.assert.equal(this.application.dispatcher.eventListeners.length, 1)
+  }
}

設計判断

キャッシュ削除を 要素がドキュメントに接続されていない場合 に限定したのは、{ once: true } オプションの動作履歴を保持したい既存ケースを壊さないためです。disconnectAction が要素の接続状態を確認して条件分岐することで、共有リスナーは依然として有効な要素が残っている間は保持され、不要になったタイミングでのみ解放されます。

この変更は API のシグネチャを拡張しただけで、従来 bindingDisconnected(binding) を呼び出すコードはそのまま動作します。clearEventListeners のデフォルトは false であるため、既存のプラグインやカスタムロジックに対する後方互換性は保持され、導入コストは事実上ゼロです。

まとめ

BindingObserver#disconnectAction が要素の接続状態を考慮して Dispatcher のキャッシュをクリアするようになり、長寿命コントローラ配下で発生していたメモリリークが解消されました。測定結果ではヒープ増加が +5.4 MB → +1.0 MB、残存したデタッチドボタンが 272 → 0 に減少し、addEventListener のバランスも改善されています。これにより、Turbo の Morph 再描画や部分更新が頻繁に行われるアプリケーションでも安定したメモリ使用が期待できます。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
bf1b7977

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文が要旨を示し、背景・技術的変更・設計判断・まとめの各セクションが揃っており、総論→各論→結論の流れが明確です。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

コードブロックは正しいシンタックスハイライト形式で記述され、GitHubリンクは PR 番号がリンク化されています。ファイル名付きハイライトが未使用でも問題ありません。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

専門エンジニア向けの技術的記述で、余計な初心者向け解説はありません。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションが総論・各論・結論の段落構成になり、段落はトピックセンテンスで始まり、1段落1トピック・適切な長さで空行で区切られています。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事中のコードブロックは提供された Diff と完全に一致しており、変更点が正確に反映されています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

用語は PR 内容と一致し、誤用は見られません。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

技術的な説明は PR の記述と矛盾せず、因果関係も正しく示されています。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

全ての主張は PR のタイトル、説明、Diff で裏付けられており、推測や捏造はありません。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

測定数値やテスト件数、PR 番号などが正確に記載されています。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事タイトルは PR の内容を適切に日本語訳しており、一致しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

PR に記載されていない外部情報は含まれていません。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

時間表現の歪曲はなく、PR の記述と整合しています。