コードブロック切り替え時のネスト選択クラッシュを防止

basecamp/lexxy

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 の互換性とテスト網を保ちつつ、実稼働環境の安定性が向上します。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
7cf26752

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

リード文が要旨を示し、背景・技術的な変更・設計判断・まとめの各章が揃っている。まとめはリード文の単なる繰り返しではなく、変更の意義を別角度で述べている。

カスタムMarkdown構文 ✓ PASS

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

コードブロックは正しい diff 言語指定で記載され、ファイル名付きハイライトは使用していないため問題なし。PR 番号リンクは [#1110](URL) 形式で正しく記述されている。

対象読者への適合性 ✓ PASS

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

エンジニア向けに専門用語と実装詳細が中心で、初心者向けの過度な説明は見られない。

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

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

各セクションは総論→各論→結論の流れになっており、段落はトピックセンテンスで始まり 1 段落 1 トピックで構成されている。段落長も 6 文未満で、空行で区切られている。

Diff内容との照合 ✓ PASS

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

記事内の diff ブロックは提供された Diff の内容と完全に一致している。抜粋しているが、核心部分は正確に反映されている。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

Invariant #19、toggleCodeBlock、#outermostElements などの用語は PR と合致しており、誤用は見られない。

説明の技術的正確性 ✓ PASS

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

技術的な説明は PR の記述と整合性があり、因果関係も論理的に示されている。

事実の突合 ✓ PASS

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

Sentry のイベント数(562 件)や影響ユーザー数(470 人)など、PR に記載された事実と一致している。外部知識や推測は含まれていない。

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

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

PR 番号 #1110、イベント数 562、ユーザー数 470 などの数値は正確。

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

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

記事タイトルは PR タイトルの意味を日本語で適切に要約しており、内容と一致している。

外部知識の正確性 ✓ PASS

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

バージョンサポートやリリーススケジュールなど、PR に無関係な外部情報は記載されていない。

時間表現の正確性 ✓ PASS

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

時間表現に関する記述はなく、矛盾もなし。