DOMから除外されたAction要素のイベントリナーキャッシュをクリアする修正
この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) → Dispatcher → Application の形で確認されています。
技術的な変更
BindingObserver#disconnectAction において、要素の接続状態を判定し bindingDisconnected に第2引数 clearEventListeners を渡すロジックを追加しました。具体的には !action.element.isConnected が true のときに clearEventListeners が true となり、切断済み要素のキャッシュがクリアされます。
@@ -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 再描画や部分更新が頻繁に行われるアプリケーションでも安定したメモリ使用が期待できます。