History

Undo and redo as explicit committed entries.

Undo has to agree with what the user saw. If two keystrokes visibly produced one word, one undo should take the word back. If a paste produced five blocks, one undo should take all five. That is a property of the commit log, not of the DOM.

SpineEditor builds history as an explicit list of committed entries. Each entry pairs the committed Transaction with the full EditorState before and after it. undo() restores stateBefore; redo() restores stateAfter. Because the engine's document is persistent, storing those references is cheap — a one-character insert shares memory with its predecessor.

Worked example

const first = engine.dispatch({ kind: "typeText", text: "a" });
const second = engine.dispatch({ kind: "typeText", text: "b" });

const beforeUndo = engine.history.done.length;
const undoResult = engine.undo();
const afterUndo = engine.state;

engine.dispatch({ kind: "typeText", text: "c" });

Adjacent typing may coalesce through the grouping policy, so beforeUndo is not guaranteed to be 2. The important invariant is that undo restores the entry's stateBefore, redo restores stateAfter, and a fresh edit clears pending redo entries.

Fields

From packages/core/src/history.ts:

type HistoryEntryKind = "edit" | "undo" | "redo";

interface HistoryEntry {
  readonly id: HistoryEntryId;
  readonly kind: HistoryEntryKind;
  readonly transaction: Transaction;
  readonly stateBefore: EditorState;
  readonly stateAfter: EditorState;
}

interface HistoryState {
  readonly done: readonly HistoryEntry[];
  readonly undone: readonly HistoryEntry[];
}

done is chronological — oldest first. undone is ordered so the most recently undone entry is at the end, ready to redo.

Trace events

The engine emits a history trace event on every push, undo, and redo:

history
  action: "push"

history
  action: "undo"

history
  action: "redo"

Why explicit entries

  • Compact. Persistent documents share structure, so stateBefore and stateAfter cost references, not deep copies.
  • Inspectable. Every HistoryEntry carries the Transaction that produced it. Tooling can walk history and see exactly which steps fired.
  • Portable. Each entry carries explicit transaction, stateBefore, and stateAfter artifacts, so tooling can persist or inspect history without depending on DOM snapshots.

API

createHistoryState(): HistoryState;
canUndo(history: HistoryState): boolean;
canRedo(history: HistoryState): boolean;
createHistoryEntry(input): HistoryEntry;
pushHistoryEntry(history, entry): HistoryState;
undoHistory(history): { history, entry, state };
redoHistory(history): { history, entry, state };
clearHistory(): HistoryState;

The engine wraps these in engine.undo() / engine.redo() / engine.canUndo() / engine.canRedo() / engine.clearHistory().