Quote ブロックでの Insert 時に発生する #211 クラッシュを防止する選択範囲正規化と NodeInserter の統合
Lexical エディタでブロックコンテナ(例: <blockquote>)上の要素ポイントにキャレットがある状態で Enter/Shift+Enter や URL 貼り付けを行うと、Invariant #211 がスローされエディタがクラッシュしていました。本 PR は選択範囲を葉ノードへ降下させる正規化処理と、ノード挿入を統一的に扱う NodeInserter を導入し、該当クラッシュを根本的に防止します。
背景
Lexical の RangeSelection.insertNodes と insertLineBreak は、選択範囲の各ポイントが インライン子を持つブロック要素 を祖先に持つことを前提としています。ブロックコンテナ上の要素ポイント(例: <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.js に INSERT_LINE_BREAK_COMMAND と INSERT_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.js と src/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.js と test/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 の貼り付けが安全に実行でき、エディタの安定性が大幅に向上します。追加されたテストにより回帰防止が確保され、設計判断は後方互換性とコードシンプルさを重視したものです。