添付ファイルの非同期更新を履歴スタック全体に反映する `RewritableHistoryExtension`
アップロード進捗などの非同期更新が誤った Undo エントリを生成していた問題を、履歴スタック全体を書き換える RewritableHistoryExtension の導入と HISTORY_MERGE_TAG の適切な付与によって解消しました。
背景
リッチテキストエディタでは、ユーザー操作(テキスト挿入、キャプション編集、添付ファイルの並び替え)のみが Undo の対象になるべきです。しかし従来の実装では、アップロード完了時のノード状態更新や NodeSelection の切り替え、ProvisionalParagraph の可視性変更といった内部的な状態変化も Undo エントリとして記録されていました。これにより、ユーザーが Undo を押すたびに意図しない中間状態に戻るという問題が発生していました。
また、Undo/Redo ボタンの有効・無効状態の管理も registerUpdateListener に依存しており、すべての更新ごとに履歴スタックを読み直すという非効率な実装になっていました。
技術的な変更
本PRの中核は、新設された RewritableHistoryExtension です。この拡張は、既存の @lexical/history の HistoryExtension に依存しつつ、REWRITE_HISTORY_COMMAND という新しいコマンドを登録します。このコマンドは、ノードキーをキーとするリライトマップを受け取り、undoStack・current・redoStack の全エントリを横断してノードを書き換えます。
// 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_TAG や SKIP_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() で復元するロジックが追加されました。これはテストのフラキネスの原因となっていたルート選択の問題に対処するものです。
設計判断
履歴の書き換えを「コマンド経由」で行う設計 が採用されています。RewritableHistoryExtension は HistoryExtension の出力から historyState への参照を取得し、コマンドハンドラ内で直接スタックを操作します。これは Lexical の通常の editor.update() フローを経由しない操作であり、Undo エントリを生成せずに既存エントリを書き換えることを可能にします。
ActionTextAttachmentUploadNode のコンストラクタ変更も設計上の重要な点です。従来は file オブジェクトが必須でしたが、file?.type ?? contentType や file?.name ?? fileName のようにオプショナルチェーンとフォールバックを使うことで、file なしでもシリアライズ済みの fileName と contentType からノードを復元できるようになりました。これは、アップロードノードが Undo 履歴から復元される際に File オブジェクトが存在しない状況に対応するために必要な変更です。
SILENT_UPDATE_TAGS というヘルパー定数は削除されました。従来は複数の更新タグをまとめてエディタ更新に渡す用途に使われていましたが、REWRITE_HISTORY_COMMAND の導入によりそのユースケースがなくなったためです。
まとめ
この変更は、非同期処理が多いリッチテキストエディタにおいて「ユーザー操作のみを Undo の単位とする」という原則を実現するための根本的な設計改善です。履歴スタックを後から書き換えられる RewritableHistoryExtension という仕組みを導入することで、アップロードの完了・失敗といった非同期イベントを Undo の粒度を壊さずにエディタ状態へ反映できる拡張ポイントが整備されました。