Overview
The historyUndo inputType is triggered when the user presses Ctrl/Cmd + Z. The browser reverts the last operation by restoring the previous DOM state from its undo stack.
Basic Behavior
Scenario: Undo after text insertion
Current state (after typing 'x')
After Ctrl/Cmd + Z (Undo)
Browser Undo Stack
- Browsers maintain their own undo/redo stack for contenteditable regions
- The stack may include multiple operations (typing, formatting, deletion, etc.)
- Undo stack depth and behavior varies between browsers
- Some browsers may group related operations into a single undo step
IME Composition + Undo
⚠️ Critical Issue
Pressing Undo during IME composition can cause unexpected behavior: the composition may be cancelled, and more text than expected may be removed from the undo stack.
See: IME & Composition for more details.
Editor Internal Model & DOM Synchronization
⚠️ Critical Exception: historyUndo May Not Fire
When editors use internal models and manipulate DOM directly:
historyUndoevent may not fire when the editor programmatically updates the DOM- Editors that use
preventDefault()and manipulate DOM directly bypass the browser's undo stack - When editors re-render DOM from their internal model (e.g., after undo in their own stack), the browser doesn't recognize this as a user-initiated undo
- Browser's undo stack may become out of sync with the editor's internal undo stack
Impact: Users pressing Ctrl/Cmd+Z may trigger the editor's internal undo, but the browser's historyUndo event may not fire, or may fire but be ignored by the editor.
Why This Happens
1. preventDefault() Breaks Browser Undo Stack
When editors call e.preventDefault() on beforeinput events and then manipulate DOM directly:
- Browser's undo stack is not updated (because default behavior was prevented)
- Editor maintains its own undo stack separately
- When user presses Ctrl/Cmd+Z, browser may try to undo from its (empty or stale) stack
- Editor intercepts the undo and uses its own stack instead
2. Programmatic DOM Updates Clear Undo Stack
When editors programmatically update DOM (e.g., innerHTML, textContent, or React re-renders):
- Browser's undo stack may be cleared (especially in Safari)
- Browser doesn't recognize programmatic changes as user actions
- Subsequent undo operations may not work as expected
3. Model-to-DOM Re-rendering
When editors re-render DOM from their internal model after undo:
- Browser sees a DOM change but doesn't know it's from an undo operation
historyUndoevent may not fire because the change wasn't initiated by browser's undo stack- Editor handles undo internally, then updates DOM, bypassing browser's undo mechanism
Best Practices for Editors
- Always preventDefault() on historyUndo: Intercept
historyUndoand use your own undo stack to avoid conflicts - Maintain separate undo stack: Don't rely on browser's undo stack when using internal models
- Sync selection after undo: After undoing in your model and updating DOM, restore the selection to match the model state
- Use document.execCommand('undo', false, null) carefully: This may trigger browser undo, which can conflict with your internal undo
- Avoid programmatic DOM updates during undo: If possible, use browser's undo stack for simple cases, or fully manage your own stack
Editor-Specific Handling
Different editor frameworks maintain their own undo/redo stacks, separate from the browser's undo stack. Here's how major editors implement historyUndo:
Undo Operation
Slate maintains its own undo/redo history using History plugin:
import { Editor, History } from 'slate';
import { withHistory } from 'slate-history';
// Initialize editor with history plugin
const editor = withHistory(createEditor());
element.addEventListener('beforeinput', (e) => {
if (e.inputType === 'historyUndo') {
e.preventDefault();
// Slate's History plugin handles undo
History.undo(editor);
}
});
// Or use React hook
import { useSlateStatic } from 'slate-react';
const editor = useSlateStatic();
function handleUndo() {
History.undo(editor);
}
- History plugin: Uses
slate-historypackage for undo/redo. - Separate stack: Maintains its own undo stack independent of browser.
- Operation-based: Tracks operations (transforms) rather than DOM states.
Undo Operation
ProseMirror uses history plugin for undo/redo:
import { undo } from 'prosemirror-history';
import { history } from 'prosemirror-history';
// Add history plugin to editor
const plugins = [history()];
view.dom.addEventListener('beforeinput', (e) => {
if (e.inputType === 'historyUndo') {
e.preventDefault();
const { state, dispatch } = view;
if (undo(state, dispatch)) {
// Handled
}
}
});
- history plugin: Built-in history plugin tracks transactions.
- undo command: Uses
undo()command to revert last transaction. - Transaction-based: Undo/redo operates on ProseMirror transactions.
Undo Operation
Draft.js maintains undo/redo stack in EditorState:
import { EditorState } from 'draft-js';
element.addEventListener('beforeinput', (e) => {
if (e.inputType === 'historyUndo') {
e.preventDefault();
// Draft.js maintains undo stack in EditorState
const undoStack = editorState.getUndoStack();
if (!undoStack.isEmpty()) {
const newState = EditorState.undo(editorState);
setEditorState(newState);
}
}
});
// Or use built-in undo method
function handleUndo() {
const newState = EditorState.undo(editorState);
setEditorState(newState);
}
- Built-in stack: EditorState maintains undo/redo stacks internally.
- EditorState.undo: Uses
EditorState.undo()to revert to previous state. - State-based: Undo/redo operates on complete EditorState snapshots.