インラインコード削除後にフォーマットが残存するバグを修正
<code> 要素内のテキストをすべて選択して削除すると、Lexicalの選択状態にコードフォーマットフラグが残存し、以降に入力したテキストが誤って <code> タグで囲まれてしまうバグを修正しました。
背景
Lexicalは選択範囲のフォーマットフラグを、コンテンツが削除された後も保持し続けるという挙動を持っています。インラインコード(<code> タグ)内のテキストをすべて選択してBackspaceキーで削除した場合、コード書式付きのコンテンツは消えているにもかかわらず、選択状態の code フォーマットフラグが残存します。この結果、ツールバーの「Code」ボタンが押下状態のまま維持され、続けて入力した新しいテキストが <code> タグで包まれてしまうという問題が発生していました。
問題の核心はLexicalの仕様にあります。selection.hasFormat("code") はフォーマットフラグの有無を返すだけで、そのフラグが実際のコード書式付きコンテンツによって裏付けられているかどうかは検証しません。そのため、コンテンツが削除された後もフラグが残存するという「staleな状態」が生まれます。
技術的な変更
この修正は src/editor/selection.js に2つの変更を加えています。getFormat() における isInCode の判定ロジックの厳密化と、staleなコードフォーマットを検出・除去するアップデートリスナーの追加です。
isInCode 判定の厳密化により、フォーマットフラグが実際のコード書式付きコンテンツに裏付けられているかを検証するようになりました。変更前は selection.hasFormat("code") のみで判定していましたが、変更後は専用の #isInCode() プライベートメソッドに委譲するようになっています。
変更前:
isInCode: selection.hasFormat("code") || $getNearestNodeOfType(anchorNode, CodeNode) !== null,
変更後:
isInCode: this.#isInCode(selection, anchorNode),
追加された #isInCode() メソッドは、コードフォーマットの正当性を段階的に検証します。まず CodeNode の祖先が存在すれば即座に true を返し、selection.hasFormat("code") が false なら即座に false を返します。最後に、アンカーノードが実際にコードフォーマットを持つテキストノードであるかどうかを確認します。
#isInCode(selection, anchorNode) {
if ($getNearestNodeOfType(anchorNode, CodeNode) !== null) return true
if (!selection.hasFormat("code")) return false
return $isTextNode(anchorNode) && anchorNode.hasFormat("code")
}
#clearStaleInlineCodeFormat() は、staleなコードフォーマットを実際に除去するためのアップデートリスナーです。エディタへの更新のたびに呼び出され、選択範囲がcollapsed(カーソル位置)でコードフォーマットフラグを持つにもかかわらず、#isInCode() が false を返す場合にのみ、フォーマットのクリアを実行します。history-merge タグや skip-dom-selection タグを持つ更新時はスキップすることで、Lexicalの内部処理との競合を避けています。
#clearStaleInlineCodeFormat() {
this.editor.registerUpdateListener(({ editorState, tags }) => {
if (tags.has("history-merge") || tags.has("skip-dom-selection")) return
let isStale = false
editorState.read(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection) || !selection.isCollapsed()) return
if (!selection.hasFormat("code")) return
const anchorNode = selection.anchor.getNode()
if (this.#isInCode(selection, anchorNode)) return
isStale = true
})
// ...
})
}
テストは test/browser/tests/formatting/inline_formatting.test.js に追加されており、<code>hello</code> のテキストを選択→削除→新しいテキスト入力という一連の操作を経て、最終的に <p>world</p> が得られることを検証しています。
設計判断
フォーマット検証ロジックとstale状態のクリアを分離した設計が採用されています。#isInCode() はフォーマットの正当性を純粋に判定する責務を持ち、#clearStaleInlineCodeFormat() はその判定を利用してstale状態を修正する責務を持ちます。この分離により、同じ検証ロジックを getFormat() とアップデートリスナーの両方で共有できています。
アップデートリスナー内では editorState.read() を使って読み取り専用のコンテキストでstale判定を行い、isStale フラグを通じて外側のスコープに結果を伝えています。これはLexicalのリアクティブモデルに沿った実装パターンで、読み取りと書き込みのコンテキストを明確に分離しています。
また、フォーマットのクリア処理を isStale フラグで条件化することで、通常の操作時にLexicalの更新サイクルへの不要な介入を回避しています。history-merge タグを持つ更新時にスキップする点も、アンドゥ/リドゥ操作との干渉を防ぐための配慮です。
まとめ
Lexicalのフォーマットフラグがコンテンツ削除後も残存するという仕様上の特性に対し、フラグの実態検証と事後クリアという2段構えのアプローチで対処しています。Lexicalのエディタ上でリッチテキスト機能をラップする際の「フレームワークの内部状態とUIの表示状態を同期させる」という典型的な課題への実践的な解法といえます。