Snapshot Undo Systems Fail When You Capture Before-State at the Wrong Moment
TLDR
Snapshot-based undo systems rely on two snapshots per edit action: the exact state before an edit, and the state after. For continuous input controls (text boxes, numeric sliders, drag handles), capturing before-state when edits commit (blur or change) captures a state that already contains user modifications, rendering undo a no-op (before === after). Capturing the before-state upon interaction start (focus, pointerdown) guarantees accurate state recovery.
| Interaction Phase | Event Hook | Captured State | Undo Result |
|---|---|---|---|
| Commit Time (Flawed) | blur / change | Value already modified | No-op (before state equals after state) |
| Interaction Start (Correct) | focus / pointerdown | Original unmodified state | 100% Deterministic Undo Recovery |
Problem statement: the before-state timing defect
Snapshot-based undo stores state pairs: { before, after }. Undo applies before; Redo applies after.
This model works smoothly for discrete canvas actions (deleting an element, placing a symbol).
However, it breaks on continuous input controls: property inputs, sliders, and drag handles. If an author types a new coordinate into a number field, when should the "before" snapshot be recorded?
Technical failure mode: identical state snapshots
Developers often trigger history pushes inside input change or blur event listeners:
// DEFECTIVE IMPLEMENTATION: Capturing before-state at commit time
$('#width-input').on('change', function() {
const before = captureState(); // BUG: Reads DOM input value AFTER modification!
applyWidth(this.value);
const after = captureState();
pushHistory('Width Edit', before, after); // before === after -> Undo does nothing! });
Because change fires after the user modifies the input value, calling captureState() inside the change handler reads the new DOM value.
The before and after snapshots become identical, rendering Ctrl+Z completely ineffective.
The fix & architecture: focus-time & pointerdown state capture
Capture the beforeState at the moment interaction begins (focus for text inputs, pointerdown for drag handles), and record the afterState when the edit commits:
let interactionBeforeState = null;
// 1. Capture before-state BEFORE modification begins $(document).on('focus', '#width-input, #height-input, #x-input', function() { interactionBeforeState = captureState(); });
// 2. Push history snapshot using the pre-captured before-state $(document).on('change', '#width-input, #height-input, #x-input', function() { if (!interactionBeforeState) return;
applyPropertyChange(this.id, this.value); const afterState = captureState();
pushHistory('Property Edit', interactionBeforeState, afterState); interactionBeforeState = null; // Clear focus capture });
Drag handle state capture
Apply the same pattern to canvas drag handles:handle.addEventListener('pointerdown', () => {
dragBeforeState = captureState(); // Capture before drag starts
});
handle.addEventListener('pointerup', () => { if (!dragBeforeState) return; pushHistory('Move Operation', dragBeforeState, captureState()); dragBeforeState = null; });
Rule of thumb: Capture the before-state at interaction initiation (focus,pointerdown), not at commit time (blur,change), to prevent identical before/after undo snapshots.