DOM-state undo/redo for self-editing HTML pages. A single MutationObserver
records primitive DOM mutations with computable inverses, batches them into
labelled commits, and replays them backward (undo) or forward (redo). Removed
subtrees are kept by reference, so undo restores the same live nodes:
event listeners, focus, scroll position, and custom-attribute wiring all
survive.
Works standalone, or auto-wired into hyperclayjs
as window.hyperclay.undo.
The DOM is the state. Undo navigates between DOM states. Each undoable operation is a transition recorded as inverse-able primitives, not a full snapshot. Snapshot-based undo re-clones from serialized HTML and loses live node identity; mutation-based replay keeps it.
npm install hyper-undoimport { undo } from 'hyper-undo'
undo.start({ scope: document.body, maxHistory: 100, bindKeys: true })
undo.commit('User edited title', () => {
document.querySelector('h1').textContent = 'New title'
})
undo.undo() // restores prior state
undo.redo() // re-applies the undone stateOr load the IIFE bundle directly (auto-attaches to window.hyperclay.undo):
<script src="https://cdn.jsdelivr.net/npm/hyper-undo/dist/hyper-undo.min.js"></script>The smooth-sailing preset includes hyper-undo and auto-starts the singleton on
document.body (in edit mode only, with bindKeys: true). Cmd+Z works out of
the box.
<script type="module">
await import('https://cdn.jsdelivr.net/npm/hyperclayjs@1/src/hyperclay.js?preset=smooth-sailing')
hyperclay.undo.commit('Add product', () => addProduct())
</script>| Call | Effect |
|---|---|
undo.start(opts) |
start the singleton on opts.scope (default document.body) |
undo.stop() |
disconnect observer, remove key bindings, clear stacks |
undo.commit(label, fn) |
run a synchronous fn, push its mutations as one labelled commit |
undo.commitCaptured(label) |
drain observer.takeRecords() and push as one commit (pause-before / commit-on-success pattern) |
undo.discardCaptured() |
drain and throw away the captured records (failure path companion) |
undo.flush() |
force-close the current idle batch as its own commit |
undo.command(label, fn) |
run a synchronous fn and push only its own writes as one labelled entry; returns { id, label }, or null when it wrote nothing |
undo.setRecording(mode) |
'automatic' (default) or 'commands'; see Recording modes |
undo.undo() / undo.redo() |
navigate history; each returns a receipt, { ok: true, entryId, label, direction } or { ok: false, code, entryId, label, direction }. undo({ entryId }) and redo({ entryId }) replay only while that entry is the next one, and refuse stale-entry otherwise |
undo.discard(entryId) |
drop the refused top entry, and only that one: { ok: true, entryId, direction }, or { ok: false, code: 'not-refused' } |
undo.clear() |
clear both stacks |
undo.pause() / undo.resume() |
recorder skips while paused |
undo.on('undo'|'redo'|'commit'|'refused'|'discard'|'clear', fn) / undo.off(...) |
subscribe to lifecycle events; undo/redo fire after a navigate with the receipt, refused with { entryId, label, direction, code, targets }, commit after a new commit, discard with { entryId, label, direction }, clear after a reset. (No generic change event.) |
undo.canUndo / undo.canRedo |
booleans (getters) |
undo.history |
[{ id, label, timestamp }, ...], oldest first; timestamp is Date.now() millis. The entry that was just refused also carries refused: { direction, code, targets } |
undo.future |
the redo stack in the same shape, next redo first |
undo.describe(entryId) |
JSON-safe description of what an entry changes, built from its certificate (null for an unknown id or an unreplayable entry); see Refusals |
undo.isPaused |
boolean (getter) |
undo.defaults |
{ shadowKeydownIn: [...] } |
undo.create(opts) |
a separate scope for advanced multi-scope use |
| Option | Default | Notes |
|---|---|---|
scope |
document.body |
element to observe |
maxHistory |
100 |
older commits drop off the back; dropped commits release their removed-node references |
idleWindowMs |
500 |
how long to wait before auto-closing a batch |
idleLabel |
'Edit' |
label for auto-closed batches |
bindKeys |
true (singleton), false (create) |
install the global Cmd+Z handler |
shadowKeydownIn |
code-editor selectors (see below) | when event.target.closest(selector) matches, the global handler bails without preventDefault |
ignoreAttribute |
null |
predicate (attrName, element) => boolean; return true to skip recording that attribute mutation, and the certificate's projection skips it too, so such a change never refuses a later undo or redo. |
isProtected |
null |
predicate (node) => boolean; return true for content that no undo or redo may erase, and an operation that would remove or replace a subtree containing such a node is refused with code "protected". |
ignoreNode |
null |
predicate (node) => boolean; in automatic mode a childList record whose every added and removed node it accepts is not recorded, so a gesture's own repeated moves leave no generic entry beside the one the consumer records with command, and such a node is left out of a bracketed entry's added and removed lists too. |
debug |
false |
console.log internal state transitions |
Raw mutation records are too fine-grained (typing "hello" is five
characterData records). Two batching modes share one collector:
- Explicit commit —
undo.commit(label, fn)wraps a synchronous chunk into one labelled commit. Throws iffn()returns a Promise (mutations after the firstawaitwould silently land in a different commit). - Idle auto-batch — mutations made outside an explicit commit collect until
the scope is idle for
idleWindowMs, then close into oneEditcommit.
automatic, the default, records every observed mutation and closes an idle batch into one Edit entry. commands records nothing on its own: only the writes a command(label, fn) (or commit) brackets become entries, so a merge, a derived repaint, a local command's own bookkeeping, or a callback that writes after its await never appears in the history. The document still changes for all of them, and a later undo sees those changes.
undo.setRecording('commands')
const entry = undo.command('Add card', () => list.append(card)) // { id, label } | null
undo.setRecording('automatic')setRecording accepts only 'automatic' and 'commands' and throws for anything else; it also throws while a command is running. The switch is a commit boundary, never a merge: work already recorded keeps its own entry, and nothing recorded after the switch joins it. A command is synchronous, never runs inside another command, and refuses a callback declared async or one that returns a promise.
Undo and redo preflight the whole entry against the live DOM without writing, and apply it only when every step still matches, so a refusal changes neither the document nor either stack:
undo.undo() // { ok: true, entryId, label, direction }
// { ok: false, code, entryId, label, direction }| Code | What changed since the entry was recorded |
|---|---|
content-changed |
an attribute, a text run, or the saved content of a subtree the step would erase is not what the step recorded |
structure-changed |
the children of a list the step edits are not in the order it recorded, or a node it would place is still attached elsewhere |
target-detached |
the element or list the step addresses is no longer in the scope |
duplicate-id |
restoring a removed subtree would bring back a data-id a live node already carries |
protected |
the step would erase a subtree containing a node isProtected accepts |
unsupported |
the step holds a shape replay cannot address or verify |
stale-entry |
undo({ entryId }) named an entry that is not on top of its stack |
replay-error |
a native write failed mid-apply; the entry is rolled back and replay-error is the code |
empty |
nothing to replay on that stack |
A refused entry keeps its place on top, and undo.history (or undo.future) shows refused: { direction, code, targets } on it, so nothing is lost and nothing is silently skipped. undo.discard(entryId) drops exactly that entry and leaves the document and every other entry as they are.
targets is one { id, kind } per node the refused step addresses. id is the nearest [data-id] ancestor, so a text step with no id of its own names the record it was typed in, and null when nothing in the ancestor chain carries one; kind says what the target was to the step (added, removed, move, protected, or the attribute or property name of a value step). The receipt's own targets are on the refused event and the history entry, so a consumer can name the record a refusal is about without asking the engine again.
undo.describe(entryId) is the plain, serializable description of one entry, built from its certificate: nothing in it is a live node, so a caller can store it. It answers null for an unknown id or an entry whose certificate could not be compiled, and otherwise:
{
id, label, timestamp,
refused: { direction, code, targets } | null, // only for the entry just refused
steps: [ /* one per step, in recorded order */ ],
}A move step is { kind: 'move', node: { id }, from: { id }, to: { id } }; an added or removed subtree is { kind: 'add' | 'remove', parent: { id }, nodes: [{ id, html }] }, where html is the recorded markup; a value, text or attribute step is { kind: 'value' | 'text' | 'attribute', target: { id }, prop?, name?, before, after }. Every nested id follows the same nearest-[data-id] rule as a refusal target.
bindKeys: true installs a window keydown capture-phase listener:
| Combo | Action |
|---|---|
| Cmd+Z / Ctrl+Z | undo |
| Cmd+Shift+Z / Ctrl+Shift+Z | redo |
| Cmd+Y / Ctrl+Y | redo (Windows convention) |
The handler short-circuits (without preventDefault) when event.target is
inside any selector in shadowKeydownIn, so an embedded editor's own keymap
handles the key. The default list covers CodeMirror v5/v6, Monaco, Ace, Quill,
Tiptap, and ProseMirror:
['.CodeMirror', '.cm-editor', '.monaco-editor', '.ace_editor', '.ql-editor', '.tiptap', '.ProseMirror']Extend it for your own editor:
undo.start({ shadowKeydownIn: [...undo.defaults.shadowKeydownIn, '.my-editor'] })The handler also returns without preventDefault when the key event's composed
path holds an input, a textarea, a select, or a contenteditable subtree
(declared or resolved, so jsdom works), when the event is an IME composition or
carries keyCode 229, and when another handler already called
preventDefault. Both Cmd+Z and Cmd+Shift+Z follow that rule.
So a plain field keeps the browser's own character-level undo, and the page
stack never undoes inside it. Typing that is not written into an attribute
records nothing: the page's Cmd+Z history starts at the write a commit or a
command makes of that text. Pass bindKeys: false to opt out entirely and
bind your own handler.
A mutation is excluded from recording when its target's ancestor chain contains
any of: mutations-ignore, save-remove, save-ignore, save-freeze. These
mirror hyperclayjs's _shouldIgnore semantics; no new attribute is introduced.
const pageUndo = undo.start() // singleton on document.body, owns Cmd+Z
const editorUndo = undo.create({ scope: editorRoot, bindKeys: false })
editorUndo.start()
// editorUndo.undo() / .redo() called manually; Cmd+Z still routes to pageUndoOnly one scope can own the global Cmd+Z binding at a time. Calling start()
again with a different scope throws (use create for additional scopes);
calling it again with the same scope warns and keeps the original config.
Pure-property <input>/<textarea> value changes are not MutationRecords, so
raw typing into a field isn't directly observable. Coverage:
- CMS form fields flow through the engine, which mutates the page DOM — the recorder sees the page mutation. ✓
[persist]inputs on a Hyperclay™ page mirrorel.valueto thevalueattribute;[persist]textareas mirror todata-value. The recorder sees the attribute mutation. ✓- Plain
<input>/<textarea>without[persist]do NOT mirror; their typing is invisible to the recorder. ✗
For an unmirrored field, do one of: add [persist], wrap the input handler in
undo.commit(label, fn), or accept that raw typing isn't undoable for that
field.
No persistence across reloads, no cross-tab sync, no semantic diffs (it records DOM ops, the label is the only semantic), no collaborative/OT undo, no tracking of non-DOM state, no "revert to saved" checkpoint.
MIT-0 (MIT No Attribution).