ブロック引用内の空リスト項目への貼り付けで発生した Lexical エラー #66 を解消

basecamp/lexxy

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 から $hasUpdateTagPASTE_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 回帰テストにより、同様のシナリオでの安定性が保証されています。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
83d9b1d2

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

リード文があり、背景(Context)、技術的変更(Technical Detail)、設計判断(任意)、まとめ(Conclusion)の順に構成されており、3部構成が明確です。

カスタムMarkdown構文 ✓ PASS

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

コードブロックは正しい diff シンタックスで記述され、GitHubリンクも PR 番号が #1108 形式でリンク化されています。ファイル名付きハイライトは使用されていませんが必須ではありません。

対象読者への適合性 ✓ PASS

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

専門的な用語と実装詳細に焦点を当てており、エンジニア向けとして適切です。初心者向けの余計な説明はありません。

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

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

各セクションは総論・各論・結論の段落構成があり、1文目がトピックセンテンスとなっています。段落は6文以下で区切られ、空行で分離されています。

Diff内容との照合 ✓ PASS

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

記事中の diff スニペットは提供された Diff 内容と完全に一致しており、ファイル名や変更箇所も正確です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

Lexical の API 名や内部関数名($hasUpdateTag, PASTE_TAG など)が正しく使用され、誤用は見られません。

説明の技術的正確性 ✓ PASS

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

問題の原因、修正内容、影響範囲が PR の記述と整合しており、技術的に正確です。

事実の突合 ✓ PASS

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

記事内の全ての主張は PR タイトル、Description、Diff で裏付けられており、推測や捏造はありません。

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

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

エラー番号 #66、イベント数 228 件、PR 番号 #1108 など数値は PR 情報と一致しています。

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

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

記事タイトルは PR タイトルの内容を正確に日本語化しており、意味的に一致しています。

外部知識の正確性 ✓ PASS

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

LTS やリリース日程など PR に無い外部情報は一切記載されていません。

時間表現の正確性 ✓ PASS

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

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