Snapshot Undo Systems Fail When You Capture Before-State at the Wrong Moment
TLDR
Snapshot undo requires two snapshots per action: the state before, and the state after. The "before" must be captured when the user starts interacting, not when the edit is committed. Any control that modifies state incrementally (a number input, a slider, a drag handle) produces wrong undo behavior if the before-snapshot is taken at commit time.
The Problem Class
Snapshot-based undo is the simplest correct undo implementation for a rich editor. Every action produces a before/after snapshot pair. Undo restores the before. Redo restores the after. No per-operation encoding. No command objects. The history is just an array of state pairs.
The implementation breaks for one category of UI control: anything that modifies state continuously before the user signals completion. Number inputs, sliders, color pickers, and drag handles all fall into this category. The user starts with a value of 10, types 25, and presses Tab. What is the before-state?
If you capture the before-state at the moment the user commits (Tab, blur, Enter), the state is already 25. The before and after are the same. Undo does nothing.
This is the before-state trap: the snapshot looks like it captures state before the change, but the change has already happened.
The Naive Approach
The intuitive implementation listens for the change or blur event on input fields and captures state at that moment:
$('#width-input').on('change', function() {
const before = captureState(); // too late: value already changed
applyWidth(this.value);
const after = captureState();
pushHistory('Width', before, after);
});
This looks correct. The change event fires after the value changes. You capture before, apply the change, capture after. The problem is that "before" here is the state after typing started, not the state before the user touched the input.
For a single keystroke this is subtle: the before captures the state with the old value because the field fires change only once, on blur. But the state has already been updated if the field applies changes incrementally on input events. And even without live-apply, if the before-snapshot captures the field's current DOM value, it reads the new value, not the old one.
Why It Breaks
Snapshot undo works on the principle that before and after are different. When before is captured after the state has already changed, they are the same snapshot. Pressing Ctrl+Z restores the state to itself. The user sees no change and assumes undo is broken.
The specific failure mode depends on how the editor applies values:
- Live-apply on
input: state is updated on every keystroke. By the timeblurfires,captureState()returns the new value. - Commit on
blur: the field's DOM value is updated immediately by the browser.captureState()reads the field's current value, which is the value the user just typed.
before reflects a state that already includes the user's change.
The Better Model
Capture the before-state at the start of the interaction, not at its end. For input fields this is the focus event. For drag handles it is pointerdown. For sliders it is the first input event after a mousedown.
let beforeState = null;
// Capture before-state when user starts interacting $('#width-input, #height-input, #rotation-input').on('focus', function() { beforeState = captureState(); });
// Commit to history using the captured before-state $('#width-input, #height-input, #rotation-input').on('blur change', function() { if (!beforeState) return; applyPropertyChange(this.id, this.value); const after = captureState(); pushHistory('Property Edit', beforeState, after); beforeState = null; });
The before-state is captured when the user's cursor enters the field, before any value change. No matter how many keystrokes the user makes, the before-state reflects the original value. The after-state is captured after the final value is applied.
For drag handles the same pattern applies:
handle.addEventListener('pointerdown', () => {
dragBeforeState = captureState();
});
handle.addEventListener('pointerup', () => { if (!dragBeforeState) return; pushHistory('Move', dragBeforeState, captureState()); dragBeforeState = null; });
Tradeoffs
Capturing at focus time means the before-state persists in memory for the duration of the interaction. For large diagrams with many elements, a full snapshot can be several hundred kilobytes of JSON. If the user focuses an input, walks away for an hour, and comes back to type, the before-state is an hour stale. For an undo system this is correct: the user is undoing the edit they made, not some later external change.
The alternative for very large state is delta-based undo: only snapshot the changed fields rather than the full state. This reduces memory but requires per-field change tracking. For most interactive editors, full snapshot undo with focus-time capture is the right tradeoff.
The One Thing to Watch For
The before-state must be captured before any input event handler runs. If you attach a live-apply handler that updates state on every keystroke, and your focus handler runs after the first input event, the before-state is already wrong. Attach focus handlers before input handlers, or guard with a flag that is only set once per interaction.