テーブルセルへの連続画像アップロード時にサイレントドロップが発生するバグを修正

basecamp/lexxy

Lexicalエディタのテーブルセルに対して連続して画像をアップロードすると、2枚目以降の画像が無音で消失するバグが修正されました。根本原因は selectEnd()シャドウルートノード 上にセレクションを着地させてしまうことで、挿入先の解決に失敗していた点です。

背景

テーブルセルへの1枚目の画像アップロード後、クリック操作なしにすぐ2枚目をアップロードすると、エディタは画像を黙って捨てていました。これはアップロード完了後のカーソル位置の正規化が、テーブル内のシャドウルートに対して適切に行われていなかったことが原因です。

具体的には、1枚目のアップロード完了後に selectEnd() を呼び出すと、セレクションが TableCellNode(Lexicalにおけるシャドウルート)自体の上に落ちます。この状態では2つの問題が生じます。まず、isOnPreviewableImage がシャドウルート上のセレクションを誤判定し、GalleryUploader 経由のフローへルーティングしてしまいます。次に、insertNodes() がシャドウルート内でブロック親ノードを見つけられず、挿入自体が失敗します。

この組み合わせにより、エラーも警告も発生させずに画像が消失するという、デバッグが困難なバグが引き起こされていました。

技術的な変更

insertAtCursor にシャドウルート上のセレクションを正規化するガード処理を追加し、セレクション変数を const から let に変更することで再代入可能にしました。

変更前:

insertAtCursor(node) {
  const selection = $getSelection() ?? $getRoot().selectEnd()
  const selectedNodes = selection?.getNodes()

  if ($isRangeSelection(selection)) {
    selection.insertNodes([ node ])
  } else if ($isNodeSelection(selection) && selectedNodes.length > 0) {
    const lastNode = selectedNodes.at(-1)
    lastNode.insertAfter(node)
  }
}

変更後:

insertAtCursor(node) {
  let selection = $getSelection() ?? $getRoot().selectEnd()
  const selectedNodes = selection?.getNodes()

  if ($isRangeSelection(selection)) {
    const anchorNode = selection.anchor.getNode()
    if ($isShadowRoot(anchorNode)) {
      const paragraph = $createParagraphNode()
      anchorNode.append(paragraph)
      selection = paragraph.selectStart()
    }
    selection.insertNodes([ node ])
  } else if ($isNodeSelection(selection) && selectedNodes.length > 0) {
    const lastNode = selectedNodes.at(-1)
    lastNode.insertAfter(node)
  }
}

セレクションのアンカーノードが $isShadowRootTableCellNode など)であると判定された場合、空の $createParagraphNode をそのセルに append し、そのパラグラフ先頭にセレクションを移動してから insertNodes を呼び出します。これにより、insertNodes がブロック親ノードを正しく見つけられる状態が保証されます。

また、insertDOM メソッドからは #unwrapPlaceholderAnchors の呼び出しと、クォートノード内への特殊挿入パス(#isInsideQuoteNode / #insertNodesIntoQuote)が削除されました。これらのコードパスが整理されたことで、selection.insertNodes(nodes) への統一フローが実現しています。インポート宣言も $isElementNode$isRootNode$isRootOrShadowRoot などシャドウルート判定に必要なユーティリティを追加する形で更新されています。

テストの追加

バグの再発を防ぐため、テーブルセルへの複数画像アップロードをカバーするブラウザテストが test/browser/tests/tables/structure.test.js に追加されました。

2つのテストケースが新設されています。一方は複数ファイルを同時に選択して一括アップロードするケース、もう一方はファイルを1枚ずつ順番にアップロードするケースです。どちらも mockActiveStorageUploads でネットワーク通信をモックした上で figure.attachment の数が期待通りか検証しており、今回修正されたバグシナリオを直接カバーしています。

設計判断

セレクションの正規化をノード挿入直前に行う方式 が採用されました。uploadFiles 側でアップロード完了後のセレクション管理を変更する代わりに、挿入処理の入口である insertAtCursor でシャドウルート上のセレクションを検出・修正することで、他のコードパスからの呼び出し時にも同様の保護が働きます。

シャドウルート上に段落を append してからセレクションを移す手法は、insertNodes が前提とする「セレクションがブロック親を持つ」という契約を満たすための最小限の介入です。セルが空の場合でも挿入後に不要な段落が残らないよう、Lexicalの通常ノード挿入フローに処理を委ねる設計になっています。

まとめ

この修正は、Lexicalのシャドウルートという概念がもたらす「セレクションが期待外の場所に着地する」問題に対して、挿入処理の入口で防御的に正規化するパターンを確立したものです。insertAtCursor へのガード追加という局所的な変更で、テーブルセルを含むすべての挿入コードパスに対して一貫した保護が実現されています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
7910da64

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

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

リード文(総論)、背景・技術的な変更・設計判断(各論)、まとめ(結論)という「総論→各論→結論」の構成が明確に守られています。各セクションの役割が明確で、非常に理解しやすい構成です。

カスタムMarkdown構文 ✓ PASS

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

ファイル名付きシンタックスハイライト(```javascript:src/editor/contents.js)とPR番号のリンク記法([#852](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

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

「シャドウルート」「セレクション」「アンカーノード」といった専門用語を適切に使用しており、対象読者であるエンジニアに適した技術レベルと表現で記述されています。

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

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

各セクションが総論→各論で構成され、各パラグラフはトピックセンテンスで始まり、1段落1トピックの原則が守られています。段落の長さも適切で、可読性が高いです。

Diff内容との照合 ✓ PASS

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

記事内で引用されている`src/editor/contents.js`のコード変更(変更前・変更後)は、提供されたDiffの内容と完全に一致しています。また、`insertDOM`メソッドの変更に関する説明も正確です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「シャドウルート(shadow root)」「TableCellNode」「selectEnd()」など、Lexicalフレームワークに関連する技術用語がPR情報と一致しており、正確に使用されています。

説明の技術的正確性 ✓ PASS

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

バグの根本原因(`selectEnd()`がシャドウルート上にセレクションを着地させる問題)と、その解決策(`insertAtCursor`でのセレクション正規化)についての説明は、PR DescriptionとDiffの内容に基づいており、技術的に正確かつ論理的です。

事実の突合 ✓ PASS

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

記事内のすべての主張(バグの原因、解決策、テストの追加、設計判断)は、PRのTitle, Description, Diffの内容によって裏付けられています。ハルシネーション(捏造)は見られません。

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

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

PR番号(#852)やファイルパス(`src/editor/contents.js`など)が正確に記載されています。

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

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

記事のタイトルは、PRのタイトル「Fix uploading multiple images one by one into a table cell」の内容を「サイレントドロップ」というPR Description内のキーワードを用いて的確に表現しており、主題と一致しています。

外部知識の正確性 ✓ PASS

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

記事の内容はすべて提供されたPR情報に基づいており、バージョンサポート状況やリリース日程といったPR外の外部知識の追記はありません。

時間表現の正確性 ✓ PASS

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

「修正されました」「でした」といった過去形の表現が使われており、完了した変更についての記述として時間表現は正確です。