ブロック引用内の空リスト項目への貼り付けで発生した Lexical エラー #66 を解消
Lexical の RangeSelection.insertNodes が空のリスト項目を削除した後に参照し、"Expected node %s to have a parent" エラーが発生していた問題を、貼り付け操作時のエスケープ判定を除外することで根本的に解決しました。これにより、ブロック引用内で Shift+Enter で作成した空リスト項目に貼り付けてもコンテンツが失われなくなります。
背景
Lexical エラー #66 は、ブロック引用内部の 空リスト項目 にキャレットがある状態でリッチテキストを貼り付けた際に発生していました。Sentry に記録された 228 件のイベントはすべて同一路径 Contents#insertDOM → insertAtCursor → RangeSelection.insertNodes を辿り、貼り付けたブロックが失われるという致命的な UX 欠陥を示していました。再現手順は、ブロック引用内で Enter して新しいリスト項目を作り、Shift+Enter で空状態にした後に貼り付けるというシンプルな操作です。
この問題は EarlyEscapeListItemNode#insertNewAfter が内部呼び出しを「エスケープジェスチャー」と誤認し、リスト項目を削除してしまう点に起因していました。削除されたノードに対して insertRangeAfter が実行され、getParentOrThrow が例外を投げてクラッシュが発生していました。根本的な対策として、貼り付け中はエスケープ判定をスキップし、通常のリスト挿入ロジックに委譲する方針が採られました。
技術的な変更
src/nodes/early_escape_list_item_node.js に対し、インポートと判定ロジックが拡張されました。まず lexical から $hasUpdateTag と PASTE_TAG を新たにインポートし、貼り付け操作かどうかを判定できるようにしました。
@@ -1,4 +1,4 @@
-import { $createParagraphNode, $splitNode, ParagraphNode } from "lexical"
+import { $createParagraphNode, $hasUpdateTag, $splitNode, PASTE_TAG, ParagraphNode } from "lexical"
次に、ブロック引用内かを判定するプライベートゲッター #isInBlockquote を追加し、内部ロジックで再利用できるようにしました。
@@
export class EarlyEscapeListItemNode extends ListItemNode {
@@
+ get #isInBlockquote() {
+ return Boolean($getNearestNodeOfType(this, QuoteNode))
+ }
#shouldEscape メソッドは大幅にリファクタリングされ、最初に貼り付け操作かどうかをチェックするガードが挿入されました。貼り付け中は false を返すことでエスケープ処理を回避し、残りのロジックは空ノードや空段落に対する従来の条件を保持しています。
@@
- #shouldEscape(selection) {
- if (!$getNearestNodeOfType(this, QuoteNode)) return false
- if ($isBlankNode(this)) return true
-
- const paragraph = $getNearestNodeOfType(selection.anchor.getNode(), ParagraphNode)
- return paragraph && $isBlankNode(paragraph) && $isListItemNode(paragraph.getParent())
- }
+ #shouldEscape(selection) {
+ if (this.#isInPasteOperation() || !this.#isInBlockquote) {
+ return false
+ } else if ($isBlankNode(this)) {
+ return true
+ } else {
+ const paragraph = $getNearestNodeOfType(selection.anchor.getNode(), ParagraphNode)
+ return paragraph && $isBlankNode(paragraph) && $isListItemNode(paragraph.getParent())
+ }
+ }
貼り付け判定は新たに導入した #isInPasteOperation ヘルパーで実装され、$hasUpdateTag(PASTE_TAG) を利用しています。
@@
+ #isInPasteOperation() {
+ return $hasUpdateTag(PASTE_TAG)
+ }
さらに、テスト が追加されました。test/browser/tests/paste/paste_into_quoted_list.test.js では、実際のブラウザ環境で上述の再現手順を自動化し、修正前はエラーが発生し、修正後は期待通りに HTML が生成されることを検証しています。
+import { test } from "../../test_helper.js"
+import { expect } from "@playwright/test"
+import { assertEditorHtml, startMonitoringConsole } from "../../helpers/assertions.js"
+
+test.describe("Paste into quoted list", () => {
+ test("pasting block content with the caret on a blank quoted list item keeps the pasted blocks", async ({
+ page,
+ editor,
+ }) => {
+ await page.goto("/attachments.html")
+ await editor.waitForConnected()
+ startMonitoringConsole(page)
+
+ await editor.setValue("<blockquote><ul><li>item</li></ul></blockquote>")
+ await editor.focus()
+
+ await editor.send("End", "Enter", "Shift+Enter")
+
+ await editor.paste("first\nsecond", { html: "<p>first</p><p>second</p>" })
+ await editor.flush()
+
+ await assertEditorHtml(
+ editor,
+ '<blockquote><ul><li value="1">item</li><li value="2"><br>first</li></ul><p>second</p></blockquote>',
+ )
+ expect(page).toHaveNoErrors()
+ })
+})
設計判断
貼り付け操作をエスケープ判定から除外する 方針は、既存の EarlyEscapeCodeNode が採用している $hasUpdateTag(PASTE_TAG) ガードと整合性を保ちました。これにより、貼り付け時にのみ例外的にエスケープロジックをスキップし、通常のリスト項目挿入と同一のコードパスが利用されます。設計上の利点は、後方互換性を維持しつつ最小限の変更でバグを解消できる点です。
変更は early_escape_list_item_node.js のみで完結しており、他のノード実装や選択ロジックへの影響はありません。テスト追加により回帰リスクも即座に検出できるようになり、リリース判断が容易になりました。エスケープ判定ロジックの分岐を増やすことはありませんでしたが、貼り付けタグの有無という明確なシグナルに依存することで、将来的な拡張や他タグとの競合も管理しやすくなります。
まとめ
本 PR は、ブロック引用内の空リスト項目へ貼り付けた際に発生していた Lexical エラー #66 を、貼り付け中のエスケープ判定を除外するシンプルなガードで解消しました。コード変更は対象ノードに限定され、既存の UX を保持しつつクラッシュを防止します。加えて追加された Playwright 回帰テストにより、同様のシナリオでの安定性が保証されています。