NodeSelectionNodeInserter がインライン貼り付け時の Lexical エラー #99 を回避
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 回実行し、全て成功しています。また、既存の attachments と paste スイートもすべてパスしています。
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 が根本的に解消されました。既存の挿入ロジックは最小限の条件分岐で拡張され、テストにより回帰がないことが保証されています。