添付ファイルの非同期更新を履歴スタック全体に反映する `RewritableHistoryExtension`

basecamp/lexxy

アップロード進捗などの非同期更新が誤った Undo エントリを生成していた問題を、履歴スタック全体を書き換える RewritableHistoryExtension の導入と HISTORY_MERGE_TAG の適切な付与によって解消しました。

背景

リッチテキストエディタでは、ユーザー操作(テキスト挿入、キャプション編集、添付ファイルの並び替え)のみが Undo の対象になるべきです。しかし従来の実装では、アップロード完了時のノード状態更新や NodeSelection の切り替え、ProvisionalParagraph の可視性変更といった内部的な状態変化も Undo エントリとして記録されていました。これにより、ユーザーが Undo を押すたびに意図しない中間状態に戻るという問題が発生していました。

また、Undo/Redo ボタンの有効・無効状態の管理も registerUpdateListener に依存しており、すべての更新ごとに履歴スタックを読み直すという非効率な実装になっていました。

技術的な変更

本PRの中核は、新設された RewritableHistoryExtension です。この拡張は、既存の @lexical/historyHistoryExtension に依存しつつ、REWRITE_HISTORY_COMMAND という新しいコマンドを登録します。このコマンドは、ノードキーをキーとするリライトマップを受け取り、undoStackcurrentredoStack の全エントリを横断してノードを書き換えます。

// Payload: Record<nodeKey, { patch?, replace? }>
//   - patch: plain object, shallow-merged into the existing node's properties
//   - replace: a LexicalNode instance that replaces the node
export const REWRITE_HISTORY_COMMAND = createCommand("REWRITE_HISTORY_COMMAND")

リライトには2種類の操作があります。patch は既存ノードのプロパティに浅いマージを行い、replace はノード自体を別のノードで置換します。現在のエディタ状態と全履歴エントリの両方に適用される設計です。

ActionTextAttachmentNode には、このコマンドを呼び出す2つのメソッドが追加されました。

patchAndRewriteHistory(patch) {
  this.editor.dispatchCommand(REWRITE_HISTORY_COMMAND, {
    [this.getKey()]: { patch }
  })
}

replaceAndRewriteHistory(node) {
  this.editor.dispatchCommand(REWRITE_HISTORY_COMMAND, {
    [this.getKey()]: { replace: node }
  })
}

従来は editor.update()HISTORY_MERGE_TAGSKIP_DOM_SELECTION_TAG をタグとして渡してサイレント更新を行っていましたが、この方式では「現在の状態だけを更新するが、過去の履歴エントリのノードは古い状態のまま」という問題が残ります。REWRITE_HISTORY_COMMAND はこれを解消し、アップロード完了後のノード状態を履歴スタック全体に伝播させます。

HISTORY_MERGE_TAG の付与箇所も整理されました。NodeSelection の作成時(#selectInLexical)と ProvisionalParagraph の可視性更新時($markAllProvisionalParagraphsDirty)に明示的に $addUpdateTag(HISTORY_MERGE_TAG) を呼び出すよう変更され、これらの操作が独立した Undo エントリとして記録されないことが保証されます。

function $markAllProvisionalParagraphsDirty() {
  // Selection-driven visibility updates must not become standalone undo steps.
  $addUpdateTag(HISTORY_MERGE_TAG)

  for (const provisionalParagraph of $getAllProvisionalParagraphs()) {
    provisionalParagraph.markDirty()
  }
}

Undo/Redo ボタンの状態管理は、registerUpdateListener によるポーリングから CAN_UNDO_COMMAND / CAN_REDO_COMMAND コマンドのリスニングへと切り替わりました。

変更前:

#monitorHistoryChanges() {
  this.#listeners.track(this.editor.registerUpdateListener(() => {
    this.#updateUndoRedoButtonStates()
  }))
}

#updateUndoRedoButtonStates() {
  this.editor.getEditorState().read(() => {
    const historyState = this.editorElement.historyState
    if (historyState) {
      this.#setButtonDisabled("undo", historyState.undoStack.length === 0)
      this.#setButtonDisabled("redo", historyState.redoStack.length === 0)
    }
  })
}

変更後:

#monitorHistoryChanges() {
  this.#listeners.track(
    this.editor.registerCommand(CAN_UNDO_COMMAND, (enabled) => { this.#setButtonDisabled("undo", !enabled) }, COMMAND_PRIORITY_LOW),
    this.editor.registerCommand(CAN_REDO_COMMAND, (enabled) => { this.#setButtonDisabled("redo", !enabled) }, COMMAND_PRIORITY_LOW),
  )
}

これにより、全更新ごとにスタックを読み直す処理がなくなり、履歴状態の変化時にのみボタン状態が更新されます。Undo ボタンは初期状態で disabled 属性付きでレンダリングされるようになりました。

ProvisionalParagraphExtension では、ルートに直接カーソルがある($isRootOrShadowRoot が真の)状態で ProvisionalParagraph が挿入された際に、カーソルが正しい位置に留まるよう $nodeBeforeRootSelection によって挿入前のカーソル位置を保存し、挿入後に selectNext() で復元するロジックが追加されました。これはテストのフラキネスの原因となっていたルート選択の問題に対処するものです。

設計判断

履歴の書き換えを「コマンド経由」で行う設計 が採用されています。RewritableHistoryExtensionHistoryExtension の出力から historyState への参照を取得し、コマンドハンドラ内で直接スタックを操作します。これは Lexical の通常の editor.update() フローを経由しない操作であり、Undo エントリを生成せずに既存エントリを書き換えることを可能にします。

ActionTextAttachmentUploadNode のコンストラクタ変更も設計上の重要な点です。従来は file オブジェクトが必須でしたが、file?.type ?? contentTypefile?.name ?? fileName のようにオプショナルチェーンとフォールバックを使うことで、file なしでもシリアライズ済みの fileNamecontentType からノードを復元できるようになりました。これは、アップロードノードが Undo 履歴から復元される際に File オブジェクトが存在しない状況に対応するために必要な変更です。

SILENT_UPDATE_TAGS というヘルパー定数は削除されました。従来は複数の更新タグをまとめてエディタ更新に渡す用途に使われていましたが、REWRITE_HISTORY_COMMAND の導入によりそのユースケースがなくなったためです。

まとめ

この変更は、非同期処理が多いリッチテキストエディタにおいて「ユーザー操作のみを Undo の単位とする」という原則を実現するための根本的な設計改善です。履歴スタックを後から書き換えられる RewritableHistoryExtension という仕組みを導入することで、アップロードの完了・失敗といった非同期イベントを Undo の粒度を壊さずにエディタ状態へ反映できる拡張ポイントが整備されました。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
fb95399b

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

リード文(総論)→背景・技術・設計(各論)→まとめ(結論)の3部構成が明確に適用されており、ガイドラインを完全に満たしています。

カスタムMarkdown構文 ✓ PASS

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

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

対象読者への適合性 ✓ PASS

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

Lexicalの内部実装に関する深い内容であり、専門知識を持つエンジニアという対象読者に完全に適合しています。冗長な説明もありません。

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

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

各セクション・各パラグラフが総論から各論へと展開されており、トピックセンテンスが明確です。1段落1トピックの原則も守られており、非常に読みやすいです。

Diff内容との照合 ✓ PASS

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

記事内で引用されているすべてのコードブロックは、ファイルパスを含め、提供されたDiffの内容と完全に一致しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

RewritableHistoryExtension, REWRITE_HISTORY_COMMAND, HISTORY_MERGE_TAGなど、PRに固有の技術用語やLexicalの専門用語が正確に使用されています。

説明の技術的正確性 ✓ PASS

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

「履歴スタックを後から書き換える」という仕組みや、Undo/Redoボタンの状態管理方法の変更など、技術的な説明がDiffの内容と整合しており、論理的かつ正確です。

事実の突合 ✓ PASS

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

記事の主張(非同期更新、NodeSelection、ProvisionalParagraphの履歴エントリ生成防止など)は、すべてPR Descriptionの記載内容で裏付けられており、ハルシネーションは検出されませんでした。

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

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

PR番号(#932)と関連するURLが正確に記載されています。

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

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

PRの簡潔なタイトル「History merging」の核心的な内容を、「添付ファイルの非同期更新を履歴スタック全体に反映する `RewritableHistoryExtension`」という具体的で分かりやすいタイトルで正確に表現しています。

外部知識の正確性 ✓ PASS

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

PR情報に含まれないバージョンサポート状況やリリース日程などの外部知識は一切含まれておらず、提供された情報に忠実です。

時間表現の正確性 ✓ PASS

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

時間表現に不自然な点や、PR情報との矛盾は見られませんでした。