Postmortem: Undo Worked for Everything Except Property Panel Edits
TLDR
Our snapshot undo system operated reliably across canvas draw, move, and delete interactions, but silently failed for property panel edits (width, height, coordinates). The root cause: the before-state for property edits was captured insidechange handlers, after input fields had updated. This produced identical before and after snapshots (before === after), making Ctrl+Z a no-op. Moving before-state capture to focus resolved the issue.
| Incident Stage | Event Listening Hook | State Snapshot Outcome | Resulting User Experience |
|---|---|---|---|
| Initial Deployment | change / blur | before equals after | No-op (Ctrl+Z does nothing) |
| Postmortem Resolution | focus (Entry Point) | before holds initial value | Reverts property changes properly |
Problem statement: the asymmetric undo failure
Canvas interactions (dragging components, placing symbols, deleting selection) worked correctly with Ctrl+Z.
However, when users modified an element's X position in the property inspector from 100 to 250 and hit Ctrl+Z, the element stayed at 250.
Pressing Ctrl+Z again skipped the position edit completely and reverted the prior canvas move.
Technical failure mode: identical state pair capture
Inspectors were binding snapshot capture directly to input change events:
// DEPRECATED: Capturing before-state inside 'change' handler
$('#prop-x').on('change', function() {
const before = captureFullState(); // BUG: Reads DOM input value AFTER modification!
applyXPosition(this.value);
const after = captureFullState();
pushHistory('X Position', before, after); // Identical state pair! });
Because change fires after user input finishes, captureFullState() read the modified DOM value for both snapshots.
The undo stack received { before: X=250, after: X=250 }. Restoring the entry simply re-applied position 250.
The fix & architecture: separating interaction start from commit
We decoupled interaction initiation (focus) from transaction commit (change):
let propBeforeState = null;
// 1. Capture before-state BEFORE user types $(document).on('focus', '#prop-x, #prop-y, #prop-w, #prop-h, #prop-rotation', () => { propBeforeState = captureFullState(); });
// 2. Commit transaction on change using the saved before-state $(document).on('change', '#prop-x, #prop-y, #prop-w, #prop-h, #prop-rotation', function() { const before = propBeforeState || captureFullState(); propBeforeState = null;
applyPropertyFromInput(this); const after = captureFullState();
pushHistory('Property Edit', before, after); });
Rule of thumb: Always separate the initiation of a user edit (focus,pointerdown) from its commit (change,pointerup). Capture before-state at initiation to guarantee valid undo history.