ファイルドロップ・ペースト時の 4 件のクラッシュを防止する修正

basecamp/lexxy

Lexxy エディタでファイルや画像をドラッグ&ドロップ・ペーストした際に発生していた 4 つの TypeError を解消し、特殊な選択状態や破棄済みエディタでも安全に動作するようになりました。これにより、ユーザー操作が原因でエディタが落ちることがなくなり、安定性が大幅に向上します。

背景

Lexxy のエディタは Contents#uploadFilesinsertAtCursor()NodeInserter.for(selection) の共通パスを通って添付ファイルを挿入しますが、Sentry に記録された 4 種類の例外が同一経路で頻発していました(Sentry BC3-JS-N7D5)。これらは「ノード選択された添付ファイル」「コードブロック内のキャレット」「画像とテキストが混在した選択」「Turbo Drive のキャッシュで破棄されたエディタ」というエッジケースで undefinednull を参照し、エディタ全体がクラッシュしていました。

この問題は UI の正しい動作を阻害するだけでなく、エディタが例外で停止するため、ユーザー体験を著しく損なう重大なバグでした。PR #1107 では、例外が発生した箇所にガードとロジック分岐を追加し、すべてのシナリオで安全に処理が完了するようにします。

結論として、エディタ内部の選択状態やライフサイクルに依存しない堅牢なファイル挿入フローが実装されました。

技術的な変更

本 PR では、4 つのクラッシュシナリオをそれぞれ対象としたコード修正を行い、共通して「存在しないオブジェクトへのプロパティ参照を防ぐ」ガードを追加しました。

NodeSelection が原因のクラッシュ修正

ShadowRootNodeInserter.handlesNodeSelection に対して selection?.anchor.getNode() を呼び出していたため、anchor が未定義で undefined.getNode() が例外となっていました。オプショナルチェイニングを正しく適用し、selection?.anchor?.getNode() に変更しました。

@@ -1,4 +1,4 @@
-import { $createLineBreakNode, $createParagraphNode, $createTextNode, $getChildCaretAtIndex, $isDecoratorNode, $isElementNode, $isLineBreakNode, $isNodeSelection, $isRangeSelection, $normalizeSelection__EXPERIMENTAL as $normalizeSelection } from "lexical"
+import { $createLineBreakNode, $createParagraphNode, $createTextNode, $getChildCaretAtIndex, $isDecoratorNode, $isElementNode, $isLineBreakNode, $isNodeSelection, $isRangeSelection, $isTextNode, $normalizeSelection__EXPERIMENTAL as $normalizeSelection } from "lexical"

この修正により、ノード選択された添付ファイル上での貼り付けやドロップが NodeSelectionNodeInserter に正常に委譲され、例外が発生しなくなります。

CodeNodeInserter の挿入ロジック改善

コードブロック内でファイルを挿入しようとすると、生成されたノードが文書に未接続のままで caret.getNodeAtCaret()null になり selectEnd がクラッシュしていました。新たに 既存ノード新規ノード を分離し、コードブロックが受け付けないノードはブロックの直後に配置するロジックを追加しました。

@@ -20,23 +20,45 @@
-    if (!node.isAttached()) continue
-    if (caret.getNodeAtCaret() && $isElementNode(node)) { caret.insert($createLineBreakNode()) }
+    if (existingNodes.has(node)) {
+      if (!node.isAttached()) continue // already pulled in when a converted ancestor was removed
+    } else if (!this.#canJoinCodeBlock(node)) {
+      trailingNodes.push(node) // e.g. a dropped attachment, which a code block can't hold
+      continue
+    }
+
+    if (caret.getNodeAtCaret() && $isElementNode(node)) { caret.insert($createLineBreakNode()) }
     caret.insert(this.#convertNodeToCodeChild(node))
@@ -32,6 +52,22 @@
-    caret.getNodeAtCaret().selectEnd()
+    const lastTrailingNode = this.#insertAfterCodeBlock(codeNode, trailingNodes)
+    const nodeToSelect = lastTrailingNode ?? caret.getNodeAtCaret()
+    nodeToSelect?.selectEnd()
   }
+
+  #canJoinCodeBlock(node) {
+    return $isTextNode(node) || $isLineBreakNode(node)
+  }
+
+  #insertAfterCodeBlock(codeNode, nodes) {
+    let previousNode = codeNode
+    for (const node of nodes) {
+      previousNode.insertAfter(node)
+      previousNode = node
+    }
+    return nodes.at(-1)
+  }

結果として、コードブロック内でドラッグやペーストしたファイルはブロックの後に正しく挿入され、エディタは例外なしで処理を完了します。

GalleryUploader の選択解決ロジック修正

画像ギャラリーへのアップロードは Selection.isOnPreviewableImage が true のときに実行されますが、選択範囲が画像からテキストへ拡張されている場合、従来はアンカー(テキストノード)からギャラリーを取得し null が返って splice が失敗していました。selection.previewableImageNode を直接参照してギャラリーを取得するよう変更しました。

@@ -69,7 +69,10 @@
-      this.#gallery = $findOrCreateGalleryForImage(this.#selectedNode)
+      // Resolve from the previewable image itself (the selection's first node), not from
+      // #selectedNode (the anchor) — those differ when the selection runs from an image
+      // into following text, and the anchor text node can't join a gallery (returns null).
+      this.#gallery = $findOrCreateGalleryForImage(this.selection.previewableImageNode)

この変更により、画像とテキストが混在した選択でも正しくギャラリーが生成され、splice が例外になることはなくなります。

Disposed Editor に対する Guard 追加

Turbo Drive の turbo:before-cache イベントでエディタが破棄されても、ドキュメントレベルのドロップゾーンから遅延でファイルが届くケースがありました。Contents#uploadFiles の冒頭で editorElement が null の場合は即座にリターンする guard を導入しました。

@@ -251,6 +251,8 @@
   uploadFiles(files, { selectLast } = {}) {
+    if (!this.editorElement) return // Disposed (e.g. on turbo:before-cache); a late drop can still land here
+
     if (!this.editorElement.supportsAttachments) {
       console.warn("This editor does not supports attachments (it's configured with [attachments=false])")
       return

テスト upload_after_editor_disposed.test.jsnull が返ることを確認し、破棄後の操作が安全に無視されることを保証しています。

結論として、4 つのクラッシュはすべて guard とロジック分岐の追加で解消され、エディタはあらゆる選択状態・ライフサイクルで安定して動作します。

設計判断

本修正では、最小限の侵入性後方互換性 を最優先に設計しました。`

  • ガードの追加は既存ロジックを変更せずに安全性だけを高め、既存ユーザーへの影響はありません。
  • オプショナルチェイニングの導入は NodeSelection の特殊性だけを対象にし、他の選択種別の振る舞いは変更しません。
  • コードブロック挿入ロジックは新たに補助メソッド #canJoinCodeBlock#insertAfterCodeBlock を導入し、コードパスを明確に分離しました。これにより将来的な拡張が容易です。
  • GalleryUploader の選択取得は Selection.previewableImageNode という新しいアクセサを介して行い、Single Responsibility Principle に沿う形で選択情報の取得責務を分離しました。
  • テスト追加により、修正がリグレッションを防ぐことを保証し、CI で自動的に検証できるようにしました。

これらの判断は、機能追加よりも安定性向上にリソースを投入すべきというプロダクト方針と合致しています。既存 API のシグネチャは一切変えておらず、ユーザーコードへの影響はゼロです。

まとめ

4 つの TypeError を根本的に防止するために、Contents#uploadFilesShadowRootNodeInserterCodeNodeInserterGalleryUploaderSelection に対し guard とロジック分岐を追加しました。これにより、ノード選択、コードブロック、画像・テキスト混合選択、Turbo Drive によるエディタ破棄といったエッジケースでもエディタがクラッシュせず、ユーザーエクスペリエンスが大幅に向上します。

記事メタデータ

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

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

リード文 → 背景 → 技術的な変更 → 設計判断(任意) → まとめという 3 部構成が明確に示されており、必須要素がすべて揃っている。

カスタムMarkdown構文 ✓ PASS

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

コードブロックは diff 表記で正しく記述され、GitHub リンクは [#1107](URL) 形式で正しくリンク化されている。ファイル名付きハイライトは使用していないため問題なし。

対象読者への適合性 ✓ PASS

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

Lexxy エディタの内部実装に関する専門的な記述が中心で、初心者向けの余計な説明はなく、対象エンジニアに適したレベルとなっている。

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

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

各セクションは総論・各論・結論のパラグラフ構成になり、トピックセンテンスが先頭に位置し、1段落1トピックで長さも適切。段落間は空行で区切られている。

Diff内容との照合 ✓ PASS

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

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

技術用語の正確性 ✓ PASS

技術用語の正確な使用

NodeSelection、CodeNodeInserter、GalleryUploader などの用語は PR と一致し、誤用は見られない。

説明の技術的正確性 ✓ PASS

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

技術的な説明は PR の記述や Diff の内容と整合しており、因果関係も論理的。誤った主張はない。

事実の突合 ✓ PASS

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

記事の主張はすべて PR のタイトル・説明・Diff で裏付けられており、外部知識や憶測は含まれていない。

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

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

PR 番号 #1107 など数値は正しく記載されている。その他数値的記述はなく、誤りもない。

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

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

記事タイトルは PR の内容(ファイルドロップ/ペースト時のクラッシュ修正)を正確に日本語で表現している。

外部知識の正確性 ✓ PASS

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

バージョンのサポート状況やリリース日程等、PR に記載されていない外部知識は一切記述されていない。

時間表現の正確性 ✓ PASS

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

時間表現の歪曲はなく、PR の記述と一致している。