Lexicalエディタのdispose実装によるリスナー・ノードリークの根本解消

basecamp/lexxy

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パターン導入

LexicalEditorElementsrc/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実装

CommandDispatchersrc/editor/command_dispatcher.js)では、#unregister配列にeditor.registerCommand()の戻り値(登録解除関数)を蓄積し、dispose()呼び出し時にwhileループで全て実行します。#registerKeyboardCommands()内で直接editor.registerCommand()を呼んでいた2箇所も#registerCommandHandler()経由に統一され、登録漏れが防がれました。

Selectionsrc/editor/selection.js)では、#clearStaleInlineCodeFormat()内のeditor.registerUpdateListener()の戻り値を#unregister配列に追加するよう変更し、Lexicalの mergeRegister ユーティリティもimportして将来の拡張に備えています。dispose()では参照をnullクリアしたうえで登録解除関数を全て実行します。

Contentssrc/editor/contents.js)はdispose()editorElementeditorの参照をnullにするシンプルな実装です。

ツールバー・ドロップダウンのdispose整備

LexicalToolbarElementsrc/elements/toolbar.js)にdispose()メソッドが追加され、ResizeObserverのアンインストール、ボタンイベントのアンバインド、ホットキー・フォーカスリスナーの解除、SelectionListener・HistoryListenerの登録解除、そしてeditorElement/editor/selectionの参照クリアを行います。また#createEditorPromise()async関数に変更され、await this.editorPromiseの結果をthis.editorElementに代入するようになりました。

ToolbarDropdownsrc/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 = () => {})として定義することで参照を固定し、正しく解除できるようになっています。LinkDropdownHighlightDropdownも同様のパターンで修正されています。

カスタム要素の冪等性(idempotency)確保

再接続時の子要素重複を防ぐため、TableToolsHighlightDropdown#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を実行後、JSEventListenersNodesのカウントを取得
  • ベースラインとの差分が0であることをアサート

あわせてtest/browser/tests/editor/reconnect.test.jsも追加され、切断・再接続後のエラーなし、および子要素の重複生成がないことを確認するテストが含まれています。

設計判断

dispose()をCustom Elementsのライフサイクルとは別のメソッドとして分離する設計が採用されました。disconnectedCallback()からdispose()を呼び出す形にすることで、エディタがDOMから切り離されない状況でも明示的に破棄できる柔軟性を持たせています。ドキュメントの更新により、ツールバーがイベント・Lexicalハンドラを登録する場合はdispose()を公開インターフェースとして実装することが規約として明文化されました。

#unregister配列をwhileループでpopしながら実行するパターンは、CommandDispatcherSelectionで統一されており、部分的に失敗した場合でも配列を消費し続ける設計になっています。また、ToolbarDropdown.#onToolbarEditor()await this.toolbar.editorConnectedからawait this.toolbar.editorElementに変更されており、ツールバーのdispose時に#createEditorPromise()でPromiseをリセットすることで次の接続サイクルに備える設計と連携しています。

まとめ

本PRは、Lexicalのコマンド・リスナー登録APIが返す登録解除関数を収集してdispose時に一括実行するパターンと、Custom Elementsのイベントハンドラをアロー関数フィールドで参照固定するパターンを組み合わせることで、エディタのライフサイクル全体にわたるリソース管理を体系化しました。CDPを用いた定量的なリークテストをCIに組み込んだことで、今後の変更でリグレッションが発生した場合も即座に検出できるようになっています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
ca6daeb2

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

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

リード文(総論)→背景・技術詳細・設計判断(各論)→まとめ(結論)という「総論→各論→結論」の3部構成が明確に適用されており、記事の骨子が非常に分かりやすいです。

カスタムMarkdown構文 ✓ PASS

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

ファイル名付きシンタックスハイライト(```javascript:filepath)やPR番号のリンク記法([#717](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

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

Custom Elementsのライフサイクル、Lexicalの内部API、メモリリークといったトピックを前提としており、専門知識を持つエンジニアという対象読者に適合した内容です。

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

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

各セクションの構成、および各パラグラフがトピックセンテンスで始まる構成が徹底されており、非常に高い可読性を実現しています。1段落1トピックの原則も守られています。

Diff内容との照合 ⚠ WARNING

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

概ねDiffの内容を正確に反映していますが、「技術的な変更」セクションの`src/elements/editor.js`のコードブロックにおいて、`this.editor`を`#disposables`配列へ追加する処理が省略されており、Diffと完全に一致していません。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「冪等性(idempotency)」「Chrome DevTools Protocol (CDP)」「disposableパターン」など、専門用語が文脈に応じて正確かつ適切に使用されています。

説明の技術的正確性 ✓ PASS

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

イベントリスナーが`bind(this)`によって解除できない問題や、CDPを用いたリークテストの仕組みなど、技術的な説明は正確で論理的です。

事実の突合 ✓ PASS

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

リード文に記載されたリスナーとノードのリーク数(+4,440件、+12,685件)がPR Descriptionの表と完全に一致しており、すべての主張がPR情報によって裏付けられています。

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

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

PR番号(#928, #717)やリーク数の具体的な数値など、記事内のすべての数値・固有名詞は正確です。

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

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

記事タイトル「Lexicalエディタのdispose実装によるリスナー・ノードリークの根本解消」は、PRの主題「Dispose lexical editor」の目的と成果を的確に要約しており、内容と完全に一致しています。

外部知識の正確性 ✓ PASS

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

記事の内容は提供されたPR情報(Title, Description, Diff)に限定されており、バージョンサポート情報などのPRに記載のない外部知識の追加はありません。

時間表現の正確性 ✓ PASS

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

「既存のパフォーマンス改善PR...で個別の修正が議論されていました」といった過去の状況に関する記述など、時間表現はPRの文脈と一致しており正確です。