コードブロック切り替え時のネスト選択クラッシュを防止
Lexical エディタのツールバーからコードブロックへ変換する際、引用や入れ子リストを跨ぐ選択で Invariant #19 がスローされ、操作がロールバックして何も起きませんでした。この PR は選択範囲の外側要素だけを対象にすることで、クラッシュを根本的に解消します。
背景
クリックしたコードブロックボタンが toggleCodeBlock を実行すると、選択が引用やネストしたリストを跨いでいる場合に Lexical が選択喪失を検知し、Invariant #19 を投げて更新を取り消します。Sentry には 562 件のイベントが記録され、約 470 人のユーザーが影響を受けていました。ロールバック後は UI 上で何も起きないため、ユーザーは操作が失敗したことに気づきません。
根本原因は、toggleCodeBlock が選択中の 全ブロック要素 を取得し、最後の要素の直後にコードノードを挿入していた点にあります。ネスト構造では祖先要素と子要素の両方がリストに含まれ、コードノードが祖先のサブツリー内部に配置されるため、祖先を削除するとコードノードも同時に切り離されます。結果として選択が削除済みノード上に残り、ノードマップチェックでエラーが発生します。
技術的な変更
src/editor/contents.js にプライベートメソッド #outermostElements が追加され、ブロック要素配列から最も外側の要素だけを抽出します。実装は各要素の祖先リストを取得し、他の要素がその祖先に含まれる場合は除外するシンプルなフィルタです。外側要素は子孫テキストを既に包括しているため、変換結果の文字列は変更前と同一です。
@@ -115,7 +115,7 @@ export default class Contents {
blockElements.forEach(node => this.#unwrapCodeBlock(node))
} else {
$expandSelectionToLineBreaksAndSplitAtEdges(selection)
- const elements = this.#blockLevelElementsInSelection(selection)
+ const elements = this.#outermostElements(this.#blockLevelElementsInSelection(selection))
if (elements.length === 0) return
@@ -441,6 +441,18 @@ export default class Contents {
return Array.from(elements)
}
+ // Selections spanning nested structures (a quote and its inner paragraphs,
+ // nested list items) yield both an element and its ancestor. Converting the
+ // ancestor detaches its whole subtree — including a node freshly inserted
+ // inside it — which can leave the selection on removed nodes (Lexical
+ // invariant #19). The outermost elements already cover their descendants'
+ // text content, so keep only those.
+ #outermostElements(elements) {
+ return elements.filter((element) => {
+ return elements.every((other) => other === element || !element.getParents().includes(other))
+ })
+ }
+
#insertUploadNodes(nodes) {
if (nodes.every($isActionTextAttachmentNode)) {
const uploader = Uploader.for(this.editorElement, [])
テストコードも追加され、ブラウザテストとユニットテストの双方でネスト選択の変換が正常に行われ、例外が発生しないことが検証されています。
設計判断
今回の修正は ロジック全体を大幅に書き換えることなく、外側要素のフィルタリングだけで問題を回避 する最小侵入型アプローチを採用しました。#outermostElements は純粋関数であり、既存の toggleCodeBlock の公開インタフェースや他のプラグインへの影響はありません。将来的にブロック取得ロジックが拡張された場合は、同様のフィルタが再利用できる設計となっています。
まとめ
toggleCodeBlock がネストした選択範囲でも例外を投げずにコードブロックへ変換できるようになり、Lexical の Invariant #19 によるロールバックが防止されました。外側要素のみを対象にする小規模な変更で、既存 API の互換性とテスト網を保ちつつ、実稼働環境の安定性が向上します。