Quote ブロックでの Insert 時に発生する #211 クラッシュを防止する選択範囲正規化と NodeInserter の統合

basecamp/lexxy

Lexical エディタでブロックコンテナ(例: <blockquote>)上の要素ポイントにキャレットがある状態で Enter/Shift+Enter や URL 貼り付けを行うと、Invariant #211 がスローされエディタがクラッシュしていました。本 PR は選択範囲を葉ノードへ降下させる正規化処理と、ノード挿入を統一的に扱う NodeInserter を導入し、該当クラッシュを根本的に防止します。

背景

Lexical の RangeSelection.insertNodesinsertLineBreak は、選択範囲の各ポイントが インライン子を持つブロック要素 を祖先に持つことを前提としています。ブロックコンテナ上の要素ポイント(例: <blockquote> の開始位置)ではこの前提が満たされず、Invariant #211("expected node to have a block ElementNode ancestor")が発生します。

この状態は通常のマウス・キーボード操作では生成されませんが、内部コマンド(Enter/Shift+Enter、URL 貼り付け)から直接呼び出されると現れ、Sentry で多数のエラーが記録されていました。エディタ全体の安定性向上が喫緊の課題となっていました。

技術的な変更

正規化ヘルパーの抽出

src/helpers/lexical_helper.js$isPointOnBlockContainer$hasPointOnBlockContainer$normalizeBlockContainerSelection の三関数を追加しました。前者はポイントがブロックコンテナ上か判定し、後者は選択範囲に該当ポイントがあれば $normalizeSelection(Lexical の実験的 API)で葉ノードへ降下させます。

export function $isPointOnBlockContainer(point) {
  if (point.type !== "element") return false
  const firstChild = point.getNode().getFirstChild()
  return ($isElementNode(firstChild) || $isDecoratorNode(firstChild)) && !firstChild.isInline()
}

export function $hasPointOnBlockContainer(selection) {
  return $isRangeSelection(selection) &&
    [selection.anchor, selection.focus].some($isPointOnBlockContainer)
}

export function $normalizeBlockContainerSelection(selection = $getSelection()) {
  if (!$hasPointOnBlockContainer(selection)) return false
  $normalizeSelection(selection)
  return true
}

高優先度ハンドラでの正規化

src/editor/command_dispatcher.jsINSERT_LINE_BREAK_COMMANDINSERT_PARAGRAPH_COMMAND 用のハンドラを COMMAND_PRIORITY_HIGH で登録し、$normalizeBlockContainerSelection を呼び出したあと false を返すことで Lexical の組み込みハンドラに処理を委譲します。これにより、ブロックコンテナ上のキャレットで改行系コマンドが実行されても事前に正規化が行われ、#211 が回避されます。

this.#registerCommandHandler(INSERT_LINE_BREAK_COMMAND, COMMAND_PRIORITY_HIGH, this.#normalizeBlockContainerSelection.bind(this))
this.#registerCommandHandler(INSERT_PARAGRAPH_COMMAND, COMMAND_PRIORITY_HIGH, this.#normalizeBlockContainerSelection.bind(this))

#normalizeBlockContainerSelection() {
  $normalizeBlockContainerSelection()
  return false
}

NodeInserter の統一利用

リンク挿入ロジックを NodeInserter.for(selection).insertNodes に置き換え、ブロックコンテナ上でも安全にノードを挿入できるようにしました。変更対象は src/editor/clipboard.jssrc/editor/contents.js の両方です。

const linkNode = $createLinkNode(url).append($createTextNode(url))
NodeInserter.for(selection).insertNodes([ linkNode ])
if ($isRangeSelection(selection)) {
  NodeInserter.for(selection).insertNodes([ linkNode ])
  linkNodeKey = linkNode.getKey()
}

BlockContainerNodeInserter のリファクタリング

src/editor/contents/node_inserter/block_container_node_inserter.js では、判定ロジックを $hasPointOnBlockContainer に置き換え、処理前に $normalizeSelection を実行するだけに簡素化しました。これにより、既存ロジックとの相違点は判定と正規化の委譲のみとなります。

static handles(selection) {
  return $hasPointOnBlockContainer(selection)
}

insertNodes(nodes) {
  $normalizeSelection(this.selection)
  this.selection.insertNodes(nodes)
}

テスト追加による回帰防止

test/browser/tests/formatting/line_break_with_caret_on_quote.test.jstest/browser/tests/paste/paste_url_with_caret_on_quote.test.js を新規追加し、要素ポイント上で Enter/Shift+Enter、プレーンテキスト URL、text/uri-list URL がクラッシュせず正しく動作することを検証しました。テストは実際のエディタ状態をプログラム的に構築し、ページエラーが無いことを確認しています。

設計判断

選択範囲の正規化を高優先度ハンドラで行う 方針が採用されました。Lexical 本体の不変条件(インライン子を持つブロック祖先が必要)を呼び出し側で事前に満たすことで、既存コマンド実装への侵入を最小限に抑え、プラグインや拡張側の影響範囲を限定しました。

NodeInserter を統一的に利用 することで、ブロックコンテナ上のノード挿入ロジックが分散せず、一箇所で判定・正規化・挿入を担う形に集約されました。これにより将来的な拡張(例: 他ブロックタイプへの対応)も同一パスで実装でき、コードベースの保守性が向上します。

まとめ

本 PR は、ブロックコンテナ上の要素ポイントで発生する #211 クラッシュを防止するために、選択範囲正規化ヘルパー・高優先度コマンドハンドラ・NodeInserter の統合という三本柱を導入しました。結果として Enter/Shift+Enter や URL の貼り付けが安全に実行でき、エディタの安定性が大幅に向上します。追加されたテストにより回帰防止が確保され、設計判断は後方互換性とコードシンプルさを重視したものです。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
0d365d1f

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

品質レビュー結果

Review Status:
リトライ後承認
Review Count:
2回 (改善を経て承認)
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

リード文がタイトル直下にあり、背景・技術的変更・設計判断・まとめの各セクションが揃っている。結論はリード文の単なる繰り返しではなく、変更の意義を補足している。

カスタムMarkdown構文 ✓ PASS

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

コードブロックは `言語:ファイルパス` 形式で正しく記述され、PRリンクは `[#1126](URL)` の形式で正しくハイパーリンク化されている。

対象読者への適合性 ✓ PASS

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

専門的なエンジニア向けの内容で、基礎的な説明は不要なほど簡素である。

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

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

各セクションは総論・各論・結論の構成を保ち、段落はトピックセンテンスで始まり、1段落1トピック・6文以下で書かれている。段落間は空行で区切られている。

Diff内容との照合 ✓ PASS

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

記事中のコードスニペットは Diff に記載された変更と完全に一致しており、ファイル名・内容・追加点すべてが正しく反映されている。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

PR で使用されている "Invariant #211"、"NodeInserter"、"INSERT_LINE_BREAK_COMMAND" などの用語が正確に使われている。

説明の技術的正確性 ✓ PASS

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

技術的な説明は PR の問題点・修正内容と矛盾せず、因果関係も論理的に正しい。

事実の突合 ✓ PASS

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

記事内の全ての主張は PR のタイトル、説明、Diff、テストケースで裏付けられており、根拠のない推測はない。

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

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

#211、#1126 といった数値・識別子は正確であり、誤りはない。

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

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

記事タイトルは PR の内容を正確に要約しており、意味的に一致している。

外部知識の正確性 ✓ PASS

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

バージョンサポートやリリース日程など、PR に記載されていない外部知識は一切含まれていない。

時間表現の正確性 ✓ PASS

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

時間表現の記載はなく、PR の記述と食い違う表現も見当たらない。