NodeSelectionNodeInserter がインライン貼り付け時の Lexical エラー #99 を回避

basecamp/lexxy

Lexical エラー #99("Only element or decorator nodes can be inserted in to the root node")が、添付ファイルをノード選択した状態でインラインコンテンツを貼り付けた際に発生していました。この PR は、NodeSelectionNodeInserter が安全でないノードを直接ルートに挿入しないようラップ処理を追加し、クラッシュを防止します。

背景

Lexical エラー #99 は、トップレベルノード(例: 添付ファイル)が NodeSelection 状態になると、NodeSelectionNodeInserter が貼り付けられた全ノードを insertAfter で選択ノード直後に配置するために発生します。インラインノード(テキストやリンク)はルートの直接子として許容されず、最初のインラインノードがルートに配置された時点でエラーがスローされます。

この問題は、実運用で約 85k 件の例外が報告された高頻度の障害であり、添付ファイルを選択したまま本文中のインラインテキストを貼り付けるという特定の操作シーケンスで再現します。エディタが破綻し、ユーザーは操作を続行できなくなる深刻な UX の欠陥でした。

技術的な変更

今回の修正は、NodeSelectionNodeInserter が挿入先がルートになるか判定し、安全でないノードに対して既存の $makeSafeForRoot ヘルパーを適用するロジックを追加したものです。これにより、インラインノードは自動的に必要なブロック要素でラップされ、ルートへの直接挿入が防がれます。

変更前と変更後の比較

変更前は選択ノード群を一度取得し、lastNode.insertAfter(node) でそのまま挿入していました。ルート安全性の判定やラップ処理は行われませんでした。

@@ -1,4 +1,5 @@
 import { $isNodeSelection } from "lexical"
+import { $makeSafeForRoot } from "../../../helpers/lexical_helper"
 import BaseNodeInserter from "./base_node_inserter"
@@
-    const selectedNodes = this.selection.getNodes()
-
-    let lastNode = selectedNodes.at(-1)
+    let lastNode = this.selection.getNodes().at(-1)

     for (const node of nodes) {
-      lastNode = lastNode.insertAfter(node)
+      const nodeToInsert = this.#insertsIntoRoot(lastNode) ? $makeSafeForRoot(node) : node
+      lastNode = lastNode.insertAfter(nodeToInsert)
     }
   }
+
+  #insertsIntoRoot(node) {
+    return node.is(node.getTopLevelElement())
+  }
 }

$makeSafeForRoot の拡張

lexical_helper.js$makeSafeForRoot は、ノードがルートに安全でない場合に $wrapNodeInElement で置換していましたが、貼り付け時のノードはまだツリーに接続されていないため失敗していました。今回の変更で、ノードが未接続(node.getParent()null)の場合は新しい親要素を作成し、append でノードをその中に入れるロジックを追加しました。

@@
-  } else {
-    return $wrapNodeInElement(node, () => node.createParentElementNode())
-  }
+  } else if (node.getParent()) {
+    return $wrapNodeInElement(node, () => node.createParentElementNode())
+  } else {
+    // Detached nodes (e.g. clipboard nodes being inserted) can't be `replace`d in place,
+    // so append them into a fresh required parent instead.
+    return node.createParentElementNode().append(node)
+  }
 }

テストの追加

upload_into_unsupported_selection.test.js に Playwright 回帰テストを追加し、添付ファイルがノード選択状態でインラインテキストを貼り付けてもエラーが発生しないことを検証しました。テストは Chromium と Firefox でそれぞれ 40 回・24 回実行し、全て成功しています。また、既存の attachmentspaste スイートもすべてパスしています。

test("inserting inline content while an attachment is node-selected keeps the root valid", async ({ page, editor }) => {
  await editor.setValue(`${pdfAttachment}<p>pasted text</p>`)
  await editor.flush()

  const figure = editor.content.locator("figure.attachment")
  await expect(figure).toBeVisible()

  await figure.click()
  await expect(figure).toHaveClass(/node--selected/)

  const errors = []
  page.on("pageerror", (error) => errors.push(error.message))

  const error = await page.evaluate(() => {
    const editorElement = document.querySelector("lexxy-editor")
    let message = null
    editorElement.editor.update(() => {
      const root = editorElement.editor.getEditorState()._nodeMap.get("root")
      const paragraph = root.getChildren().find((child) => child.getType() === "paragraph")
      const textNode = paragraph.getFirstChild()
      textNode.remove()
      try {
        editorElement.contents.insertAtCursor(textNode)
      } catch (err) {
        message = err.message
      }
    })
    return message
  })

  await editor.flush()

  expect(error).toBeNull()
  expect(errors).toEqual([])
  await expect(figure).toHaveCount(1)
  await expect(editor.content).toContainText("pasted text")
  expect(await editor.plainTextValue()).toContain("pasted text")
})

設計判断

$makeSafeForRoot を再利用したことにより、ノード安全化ロジックの一元管理が実現しました。各ノードの createParentElementNode が宣言する必須親要素タイプを尊重し、要素・デコレーターノードはそのまま挿入されます。これにより、既存の貼り付けフローに最小限の変更で安全性を付与できます。

ヘルパーはもともと「既にツリーに接続されたノード」前提で実装されていましたが、今回の修正で「未接続ノード」パスを追加しました。 detached ノードは新たに作成した親要素へ append することで置換失敗を回避し、インラインテキストやリンクが安全にルートに挿入されます。インサーター側で別途ラップ処理を実装する必要がなくなり、コードの重複を防いでいます。

この変更は要素ノードやデコレーターノードの既存挙動を保持したまま、インラインノードだけを安全にラップします。判定ロジックは #insertsIntoRoot でルート判定を行い、真の場合にのみヘルパーを呼び出すだけのシンプルな分岐です。パフォーマンスへの影響は極めて小さく、回帰テストでも既存機能がすべて通過していることから、互換性を損なうことなくクラッシュを防止できています。

まとめ

NodeSelectionNodeInserter がインラインノードを安全にラップすることで、Lexical エラー #99 が根本的に解消されました。既存の挿入ロジックは最小限の条件分岐で拡張され、テストにより回帰がないことが保証されています。

記事メタデータ

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

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

リード文、背景、技術的変更、設計判断、まとめの各要素が揃っており、総論→各論→結論の構成が明確に示されています。

カスタムMarkdown構文 ⚠ WARNING

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

GitHubリンクが要求された形式 `[<#番号>](URL)` ではなく、`[PR #1127](URL)` の記述になっています。カスタムシンタックスハイライトは使用されていませんが、誤りはありません。

対象読者への適合性 ✓ PASS

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

LexicalやNodeSelection等の専門用語が前提で記述されており、エンジニア向けに適切なレベルです。

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

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

各セクションは総論・各論・結論の段落構成が守られ、トピックセンテンスで始まり、1段落1トピック、段落長も適切です。

Diff内容との照合 ✓ PASS

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

記事中のdiffブロックは提供されたPRのdiffと完全に一致しており、コード変更が正確に反映されています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

使用されている技術用語($makeSafeForRoot、NodeSelectionNodeInserter など)はPRと一致し、誤用はありません。

説明の技術的正確性 ✓ PASS

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

技術的な説明はPRの問題点・原因・修正内容と整合しており、根拠が示されています。

事実の突合 ✓ PASS

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

エラー件数やテスト結果など、すべてPRで示された情報に基づく記述で、捏造や憶測はありません。

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

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

PR番号 #1127、エラー番号 #99、テスト実行回数(40/40, 24/24)など、数値はPRと一致しています。

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

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

記事タイトルはPRの課題と修正内容を的確に反映しており、矛盾はありません。

外部知識の正確性 ✓ PASS

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

記事はPRに記載された情報以外の外部知識(LTS、リリース日程等)を付加していません。

時間表現の正確性 ✓ PASS

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

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