エクステンションに `dispose()` ライフサイクルフックを追加してリソースリークを解消
Lexxyのエクステンション基底クラスに dispose() メソッドが追加され、エディタの切断・再接続時にリスナーやタイマーなどのリソースを確実にクリーンアップできるようになりました。Turboなどのナビゲーションによる back/forward 移動で発生していた重複ハンドラ問題が根本的に解消されます。
背景
エディタ要素の connectedCallback が呼ばれるたびに新しい Extensions インスタンスが生成されていましたが、旧インスタンスが保持するリソースを解放する手段がありませんでした。initializeToolbar() でツールバーに追加したリスナーは、エディタが DOM から切り離された後も生き続け、次の connectedCallback で再度登録されるため、N 回の再接続後にハンドラが N 回発火するという問題が生じていました。
この問題が特に顕在化するのは、Turbo の back/forward ナビゲーションのケースです。Turbo はツールバーの DOM を切断・再接続のサイクルをまたいで保持するため、エクステンションが initializeToolbar() でツールバーに付与したリスナーが再接続のたびに蓄積されていきました。
技術的な変更
変更は4つのファイルにまたがり、ライフサイクルフックの宣言・集約呼び出し・エディタへの登録という3段階の構造になっています。
LexxyExtension への no-op メソッド追加として、src/extensions/lexxy_extension.js の基底クラスに空の dispose() が定義されました。サブクラスは必要に応じてオーバーライドするだけでよく、実装しないエクステンションは何もしない既定の振る舞いをそのまま継承します。
dispose() {
}
Extensions.dispose() による逆順クリーンアップとして、src/editor/extensions.js には while ループで pop() しながら各エクステンションの dispose() を呼ぶメソッドが追加されました。
dispose() {
while (this.enabledExtensions.length) {
this.enabledExtensions.pop().dispose()
}
}
pop() を使うことで、初期化の逆順でクリーンアップが走ります。依存関係のあるエクステンション同士が正しい順序で解体されるよう配慮された実装です。
エディタへの disposables 登録として、src/elements/editor.js では this.#disposables.push(this.extensions) の1行が追加されました。これにより、エディタ本体やリスナーと同じタイミングで Extensions.dispose() が自動的に呼ばれるようになり、呼び出し側が意識する必要はありません。
this.extensions = new Extensions(this)
this.#disposables.push(this.extensions) // 追加
this.editor = this.#createEditor()
this.#disposables.push(this.editor)
設計判断
既存の #disposables パターンへの統合という方針が採用されました。エディタ要素はすでにエディタ本体やリスナーを #disposables 配列で管理しており、Extensions インスタンスをその配列に追加するだけで済む設計になっています。新たなライフサイクル管理の仕組みを設けず、既存のパターンを再利用した判断です。
AbortController を活用したテストフィクスチャも注目に値します。追加されたブラウザテスト (test/browser/fixtures/extension-dispose.js) では、#abortController の signal を addEventListener に渡すことで、dispose() 内の abort() 一呼び出しですべてのリスナーを一括解除しています。これはエクステンション実装のリファレンスパターンとして機能します。
class DisposableListenerExtension extends Extension {
#abortController = new AbortController()
initializeToolbar(toolbar) {
toolbar.addEventListener(
"lexxy-test-ping",
() => { window.__lexxyDisposeTest_pingCount = (window.__lexxyDisposeTest_pingCount || 0) + 1 },
{ signal: this.#abortController.signal }
)
}
dispose() {
window.__lexxyDisposeTest_disposeCount = (window.__lexxyDisposeTest_disposeCount || 0) + 1
this.#abortController.abort()
}
}
ブラウザテストは「切断→再接続後に dispose() が1回呼ばれること」「ping イベントが再接続後も1回だけカウントされること」の2点を検証しており、リグレッションを防ぐ安全網となっています。
まとめ
dispose() ライフサイクルフックの追加は、Turbo などのナビゲーションを前提とした SPA 環境でエクステンションを安全に運用するための基盤となる変更です。既存の #disposables パターンへの統合により、エクステンション開発者は dispose() をオーバーライドするだけでリソース解放を正しいタイミングに委ねられるようになりました。