Lexicalエディタのdispose実装によるリスナー・ノードリークの根本解消
Lexicalエディタ要素の切断時にeditor.dispose()を呼び出す仕組みを整備し、イベントリスナーとDOMノードのリークをゼロにしました。10回の再接続サイクルで+4,440件のリスナーと+12,685件のノードが蓄積していた問題が、本PRの変更後は増加ゼロになっています。
背景
LexxyはLexicalベースのリッチテキストエディタをCustom Elementsとして実装しており、lexxy-editor要素の切断と再接続(reconnect)が繰り返される状況でリソースリークが発生していました。既存のパフォーマンス改善PR #717 でもCommandDispatcherへのdestroy()追加やUpdateListenerの登録解除など個別の修正が議論されていましたが、エディタ全体を統一的に破棄する仕組みが欠けていたため、根本的な解消には至っていませんでした。
具体的には、disconnectedCallback時にイベントリスナーやLexicalのコマンドハンドラ・UpdateListenerが解除されないまま参照が残り続けていました。加えて、Custom Elementsが再接続のたびに子要素を重複生成するという問題も同時に存在していました。
技術的な変更
エディタ要素のdisposableパターン導入
LexicalEditorElement(src/elements/editor.js)に #disposables 配列が追加され、各サブコンポーネントをdisconnectedCallback時にまとめて破棄できるようになりました。
変更前:
this.contents = new Contents(this)
this.selection = new Selection(this)
this.clipboard = new Clipboard(this)
CommandDispatcher.configureFor(this)
変更後:
this.contents = new Contents(this)
this.#disposables.push(this.contents)
this.selection = new Selection(this)
this.#disposables.push(this.selection)
this.clipboard = new Clipboard(this)
const commandDispatcher = CommandDispatcher.configureFor(this)
this.#disposables.push(commandDispatcher)
CommandDispatcher.configureFor()はこれまで戻り値なし(void)でしたが、インスタンスを返すよう変更され、#disposablesへの登録が可能になっています。
各コンポーネントへのdispose実装
CommandDispatcher(src/editor/command_dispatcher.js)では、#unregister配列にeditor.registerCommand()の戻り値(登録解除関数)を蓄積し、dispose()呼び出し時にwhileループで全て実行します。#registerKeyboardCommands()内で直接editor.registerCommand()を呼んでいた2箇所も#registerCommandHandler()経由に統一され、登録漏れが防がれました。
Selection(src/editor/selection.js)では、#clearStaleInlineCodeFormat()内のeditor.registerUpdateListener()の戻り値を#unregister配列に追加するよう変更し、Lexicalの mergeRegister ユーティリティもimportして将来の拡張に備えています。dispose()では参照をnullクリアしたうえで登録解除関数を全て実行します。
Contents(src/editor/contents.js)はdispose()でeditorElementとeditorの参照をnullにするシンプルな実装です。
ツールバー・ドロップダウンのdispose整備
LexicalToolbarElement(src/elements/toolbar.js)にdispose()メソッドが追加され、ResizeObserverのアンインストール、ボタンイベントのアンバインド、ホットキー・フォーカスリスナーの解除、SelectionListener・HistoryListenerの登録解除、そしてeditorElement/editor/selectionの参照クリアを行います。また#createEditorPromise()がasync関数に変更され、await this.editorPromiseの結果をthis.editorElementに代入するようになりました。
ToolbarDropdown(src/elements/toolbar_dropdown.js)では、disconnectedCallbackでイベントリスナーを正しく削除できていなかった根本的な問題が修正されています。
変更前:
disconnectedCallback() {
this.container.removeEventListener("keydown", this.#handleKeyDown.bind(this))
}
変更後:
disconnectedCallback() {
this.container?.removeEventListener("toggle", this.#handleToggle)
this.container?.removeEventListener("keydown", this.#handleKeyDown)
}
変更前はbind(this)で毎回新しい関数参照を生成していたためremoveEventListenerが実質無効でした。ハンドラをクラスフィールドのアロー関数(#handleToggle = () => {})として定義することで参照を固定し、正しく解除できるようになっています。LinkDropdownやHighlightDropdownも同様のパターンで修正されています。
カスタム要素の冪等性(idempotency)確保
再接続時の子要素重複を防ぐため、TableToolsとHighlightDropdownの#setUpButtons()先頭にthis.innerHTML = ""が追加されました。CodeLanguagePickerでは#findLanguagePicker()メソッドが追加され、querySelector("select")で既存要素を再利用し、存在しない場合のみ新規作成するようになっています。
リークテストの追加
test/browser/tests/editor/leak.test.jsが新設され、Chrome DevTools Protocol(CDP)を使用したリーク検証が自動化されました。
テストの手順は以下のとおりです:
-
Performance.enableでCDPメトリクスを有効化 - ウォームアップとして1回再接続し、遅延初期化分をベースラインから除外
- 10サイクルの再接続を実行
-
HeapProfiler.collectGarbageでGCを実行後、JSEventListenersとNodesのカウントを取得 - ベースラインとの差分が
0であることをアサート
あわせてtest/browser/tests/editor/reconnect.test.jsも追加され、切断・再接続後のエラーなし、および子要素の重複生成がないことを確認するテストが含まれています。
設計判断
dispose()をCustom Elementsのライフサイクルとは別のメソッドとして分離する設計が採用されました。disconnectedCallback()からdispose()を呼び出す形にすることで、エディタがDOMから切り離されない状況でも明示的に破棄できる柔軟性を持たせています。ドキュメントの更新により、ツールバーがイベント・Lexicalハンドラを登録する場合はdispose()を公開インターフェースとして実装することが規約として明文化されました。
#unregister配列をwhileループでpopしながら実行するパターンは、CommandDispatcherとSelectionで統一されており、部分的に失敗した場合でも配列を消費し続ける設計になっています。また、ToolbarDropdown.#onToolbarEditor()がawait this.toolbar.editorConnectedからawait this.toolbar.editorElementに変更されており、ツールバーのdispose時に#createEditorPromise()でPromiseをリセットすることで次の接続サイクルに備える設計と連携しています。
まとめ
本PRは、Lexicalのコマンド・リスナー登録APIが返す登録解除関数を収集してdispose時に一括実行するパターンと、Custom Elementsのイベントハンドラをアロー関数フィールドで参照固定するパターンを組み合わせることで、エディタのライフサイクル全体にわたるリソース管理を体系化しました。CDPを用いた定量的なリークテストをCIに組み込んだことで、今後の変更でリグレッションが発生した場合も即座に検出できるようになっています。