空ノード選択時のアップロードクラッシュを防止

basecamp/lexxy

Lexxy のドキュメントレベルのファイルドロップが、削除済みノードが残す 空の NodeSelection によりクラッシュしていた問題を、選択状態のフォールバックと Inserter の判定を改良することで解消しました。

背景

この PR は Sentry の BC3-JS-N7D5 (Issue #7507327343) に起因するクラッシュを対象としています。ドロップゾーンがエディタにファイルを転送する際、直前に選択されていたノードが削除されると、エディタは ノード選択が存在しない状態 を保持し、Contents#uploadFiles が例外を投げていました。

根本原因は Contents#insertAtCursor$getSelection()null の場合のみ文書末へフォールバックしていた点です。空の NodeSelection は null ではなく、取得できるノードが0 個というだけであり、このケースはフォールバック対象に含まれていませんでした。そのため、空選択がそのまま NodeInserter に渡されました。

NodeSelectionNodeInserter.insertNodes は選択されたノードの最後要素 (this.selection.getNodes().at(-1)) を取得し、lastNode.insertAfter(...) を実行します。空のノード配列では lastNodeundefined となり、isinsertAfter の呼び出しで "Cannot read properties of undefined" エラーが発生していました。

技術的な変更

src/editor/contents.js$isNodeSelection のインポートを追加し、プライベートメソッド #insertableSelection() を導入しました。このメソッドは現在の選択を取得し、空の NodeSelection であれば文書末 ($getRoot().selectEnd()) を返し、そうでなければ元の選択または null の場合は同様に文書末へフォールバックします。

@@ -1,6 +1,6 @@
 import {
   $createLineBreakNode, $createParagraphNode, $createTextNode, $getNodeByKey, $getRoot, $getSelection, $hasUpdateTag,
-  $isLineBreakNode, $isParagraphNode, $isRangeSelection, $isRootOrShadowRoot, $isTextNode, $setSelection,
+  $isLineBreakNode, $isNodeSelection, $isParagraphNode, $isRangeSelection, $isRootOrShadowRoot, $isTextNode, $setSelection,
   HISTORY_MERGE_TAG, PASTE_TAG,
   SELECTION_INSERT_CLIPBOARD_NODES_COMMAND
 } from "lexical"
@@
   insertAtCursor(...nodes) {
-    const selection = $getSelection() ?? $getRoot().selectEnd()
+    const selection = this.#insertableSelection()
     const inserter = NodeInserter.for(selection)

     inserter.insertNodes(nodes)
@@
   #insertableSelection() {
-    const selection = $getSelection()
-    if ($isNodeSelection(selection) && selection.getNodes().length === 0) {
-      return $getRoot().selectEnd()
-    }
-
-    return selection ?? $getRoot().selectEnd()
+    const selection = $getSelection()
+    if ($isNodeSelection(selection) && selection.getNodes().length === 0) {
+      return $getRoot().selectEnd()
+    }
+
+    return selection ?? $getRoot().selectEnd()
   }

src/editor/contents/node_inserter/node_selection_node_inserter.js では、ハンドラ判定を厳密化し、空のノード選択を扱わないようにしました。

@@ -4,7 +4,7 @@ import BaseNodeInserter from "./base_node_inserter"

 export default class NodeSelectionNodeInserter extends BaseNodeInserter {
   static handles(selection) {
-    return $isNodeSelection(selection)
+    return $isNodeSelection(selection) && selection.getNodes().length > 0
   }

テスト upload_into_unsupported_selection.test.js に空ノード選択からのファイルアップロードケースを追加し、エラーが発生しないことと、添付がドキュメント末尾に正しく挿入されることを検証しています。

設計判断

Contents#insertAtCursor が空のノード選択を 欠損選択と同等に扱う 方針は、ドキュメント全体で統一されたフォールバックロジックを提供します。これにより、ドロップ、貼り付け、ツールバーからの挿入といった全エントリポイントが同一の挙動を保証し、個別のガードを書き散らす必要がなくなります。

NodeSelectionNodeInserter.handlesノード数 > 0 の条件を加えることで、Inserter 自体が不正な状態に入らないことを保証しています。判定ロジックが Inserter の責務に収まることで、Contents 側のロジックはシンプルに保たれ、将来的な Inserter の拡張が容易になります。

この変更は STYLE.md の fail‑fast ガイドラインに沿っており、空のノード選択はオプション状態 とみなして早期に安全なデフォルトへ遷移させる設計です。未サポート状態を例外で隠すのではなく、自然に文書末への挿入へ導くことでユーザー体験が向上します。

記事メタデータ

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

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

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ⚠ WARNING

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

リード文はあるが、最後に明確な「まとめ」セクションが欠如している。設計判断は含まれるが、結論としての要点整理がないためWARNINGとした。

カスタムMarkdown構文 ⚠ WARNING

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

コードブロックはdiff形式で正しく表示されているが、ファイル名付きハイライトは使用されていない。GitHubリンクは "[PR #1139](URL)" という形式で、ガイドラインの "[#1139](URL)" とは異なるため警告。

対象読者への適合性 ✓ PASS

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

専門的なLexical/Lexxyの内部実装に関する記述で、エンジニア向けに適切なレベルの内容となっている。

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

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

各セクションは総論→各論→結論の流れが保たれ、段落はトピックセンテンスで始まり1段落1トピック、長さも適切。空行で区切られている。

Diff内容との照合 ✓ PASS

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

記事内のdiffブロックは提供されたPR Diffと一致しており、ファイル名・変更内容ともに正確に反映されている。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

$isNodeSelection、$getRoot().selectEnd() などの用語はPRで使用されている通りで誤用は見られない。

説明の技術的正確性 ✓ PASS

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

空の NodeSelection がクラッシュを引き起こす原因と、追加したガードロジックの効果がPRの説明と合致している。

事実の突合 ✓ PASS

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

すべての主張はPRタイトル、Description、Diffで裏付けられている。外部情報の付加はない。

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

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

Issue番号 #7507327343、PR番号 #1139 などの数値は正確。その他数値的記述はなし。

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

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

日本語タイトルは PR の "Skip uploads into an empty node selection" の意味を適切に翻訳しており、一致している。

外部知識の正確性 ✓ PASS

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

記事に含まれる外部知識(Sentry Issue、STYLE.md のガイドライン等)はPR情報に含まれており、余計な推測はない。

時間表現の正確性 ✓ PASS

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

時間表現の記載はなく、PR情報との不整合はなし。