Lexicalの初期化をrAFで遅延させてナビゲーションのラグを解消
Lexxyエディタの初期化処理を次のアニメーションフレーム(rAF)まで遅延させることで、Turbo morphと同時に発生していたナビゲーション時のラグスパイク(50〜100ms)を解消しました。
背景
LexxyをTurbo morphと組み合わせて使用した際、ナビゲーションに50〜100msの視覚的なハングが生じていました。根本原因はLexicalの持つ強制レイアウトフラッシュにあります。
LexicalはsetRootElementの中で選択ハンドラがSelection.anchorNodeを参照します。この参照がブラウザに即時レイアウトフラッシュを強制し、ペンディング中のミューテーションの確定、スタイルの無効化、再計算を一斉に引き起こします。Turbo morphがDOMを更新している最中にLexxyが初期化されると、このフラッシュがmorph由来の変更すべてを道連れにし、ペイントが遅延します。
遅延初期化により、ナビゲーションとDOMのペイントが先に完了した後でLexxyが初期化されるため、レイアウト計算の対象が大幅に減り、初期化自体も高速に完了します。
技術的な変更
connectedCallback内の初期化フローをrequestAnimationFrameでラップし、DOM確定後に実行されるよう変更しました。
変更前:
this.#initialize()
this.#scheduleEditorInitializedDispatch()
this.toggleAttribute("connected", true)
this.#handleAutofocus()
this.valueBeforeDisconnect = null
変更後:
this.#initialize()
this.toggleAttribute("connected", true)
requestAnimationFrame(() => {
this.#mountRoot()
this.#handleAutofocus()
this.#dispatchInitialize()
})
getRootElement()はrAF前はnullを返し、rAF後にエディタのルート要素を返します。これはテストでも明示的に検証されています。
rAFへの移行に伴い、既存の#scheduleEditorInitializedDispatch / #cancelEditorInitializedDispatchの仕組みはフラグベースの管理に置き換えられました。具体的には#initializeEventDispatched・#editorInitializedDispatched・#valueLoadedの3つのboolean フラグが導入されています。disconnectedCallbackではこれらのフラグをリセットし、#valueLoadedがtrueの場合のみvalueBeforeDisconnectを保存することで、rAF実行前に切断された場合の不正な値保存を防いでいます。
イベントリスナーの登録方法も合わせて変更されています。command_dispatcher.jsとselection.jsでは、getRootElement()で取得した要素に直接リスナーを付けていた箇所がeditor.registerRootListenerベースの実装に移行しました。これにより、ルート要素がnullから実要素に変化するタイミングに合わせてリスナーが登録・解除されるようになり、rAF遅延との整合性が取れています。
// 変更前: getRootElement()で直接取得
const root = this.editor.getRootElement()
registerEventListener(root, "dragover", ...)
// 変更後: registerRootListenerでライフサイクルに追従
this.editor.registerRootListener((rootElement) => {
if (rootElement) {
const teardowns = [
registerEventListener(rootElement, "dragover", ...),
...
]
return () => teardowns.forEach((teardown) => teardown())
}
})
設計判断
rAFコールバック内へのまとめ方が設計上の重要なポイントです。mountRoot・handleAutofocus・dispatchInitializeを1つのrAFコールバックにまとめることで、「ルート要素がマウントされる前にイベントが発火する」「オートフォーカスがレイアウト前に実行される」といった競合状態を一括して防いでいます。
ルート要素のライフサイクルをregisterRootListenerで管理する方針は、rAFによる遅延をイベントリスナー側に意識させない設計です。リスナーはルート要素が存在する間だけ有効であることが保証され、遅延の有無に関わらず正しく動作します。またselection.jsの#containEditorFocusでは、インラインのイベントハンドラが#handleArrowKeyOnLexicalCursorメソッドとして分離されており、リスナーの登録・解除とロジックの関心を分けています。
まとめ
requestAnimationFrameによる1フレームの遅延という小さな変更で、Turbo morphとの競合が引き起こす強制レイアウトフラッシュという複合的な問題を解消しています。registerRootListenerへの統一は、遅延初期化のための場当たり的な対処ではなく、ルート要素のライフサイクルをより堅牢に扱うための設計改善として機能しています。