historyUndo

How historyUndo inputType performs undo operations and varies across browsers.

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')

HTML:
<p>Hello x world</p>

After Ctrl/Cmd + Z (Undo)

HTML:
<p>Hello world</p>
Previous state restored from undo stack

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:

  • historyUndo event 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
  • historyUndo event 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 historyUndo and 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:

Slate.js

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-history package for undo/redo.
  • Separate stack: Maintains its own undo stack independent of browser.
  • Operation-based: Tracks operations (transforms) rather than DOM states.
ProseMirror

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.
Draft.js

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.

Related resources