インラインコード削除後にフォーマットが残存するバグを修正

basecamp/lexxy

<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の表示状態を同期させる」という典型的な課題への実践的な解法といえます。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
04f028c0

この記事は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:path/to/file.js)とPR番号のリンク記法([PR #889](URL))がガイドラインに従って正しく使用されています。

対象読者への適合性 ✓ PASS

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

Lexicalフレームワークに関する専門用語を前提とした解説となっており、専門知識を持つエンジニアという対象読者に適切です。

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

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

各セクションが総論→各論で構成され、各段落がトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が遵守されており、高い可読性を実現しています。

Diff内容との照合 ✓ PASS

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

記事内で引用されているコードブロックは、提供されたDiffの内容と完全に一致しており、変更点を正確に反映しています。テストコードに関する言及も正確です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「フォーマットフラグ」「アップデートリスナー」「staleな状態」など、Lexicalや関連する技術文脈で使われる用語が正確かつ適切に使用されています。

説明の技術的正確性 ✓ PASS

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

Lexicalの仕様に起因する問題の根本原因から、実装された解決策のロジックまで、技術的な説明は正確で論理的です。

事実の突合 ✓ PASS

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

記事内のすべての主張は、PRのTitle, Description, Diffの内容によって裏付けられており、ハルシネーションは検出されませんでした。「設計判断」の解説もコードから読み取れる事実に即しています。

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

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

PR番号(#889)が正確に記載・リンクされています。

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

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

記事のタイトルはPRの主題「Fix code formatting stuck after deleting inline code」を的確に要約しており、内容との整合性が取れています。

外部知識の正確性 ✓ PASS

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

記事には、PR情報に基づかないバージョン情報やリリース予定などの外部知識は含まれていません。

時間表現の正確性 ✓ PASS

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

時間表現は、発生した問題とその修正という過去の事実を正確に記述しており、歪曲は見られません。