Engineering Journal
Schema Editor
Schema Editor

Postmortem: Undo Worked for Everything Except Property Panel Edits

2026-06-04

TLDR

Undo worked correctly for all canvas operations (draw, move, delete) and silently did nothing for property panel edits (x, y, width, height). The cause: before-state for property edits was captured at blur time, after the input value had already changed. Every undo restored the same state it was undoing.


The Assumption That Seemed Reasonable

The undo history system used full-state snapshots. Every operation that changed the diagram captured a before and after snapshot and pushed the pair onto the stack.

For canvas operations (drawing a shape, moving an element, deleting a selection), the pattern was clear: capture before the operation, execute the operation, capture after. This worked correctly.

For property panel inputs, the same pattern was applied at the change event:

$('#prop-x').on('change', function() {
    const before = captureFullState();
    applyXPosition(this.value);
    const after = captureFullState();
    pushHistory('X Position', before, after);
});

The reasoning was sound: change fires when the value is committed. Capture before applying, apply, capture after. This should produce a before/after pair with different states.

The problem was that captureFullState() reads the current value of the input field as part of the state snapshot. By the time the change handler runs, the input field already shows the new value. So "before" captures the new value, not the old one. The before and after snapshots are identical.

When It Failed

During testing, a user edited the X position of an element from 100 to 250. They pressed Ctrl+Z. Nothing changed. They pressed Ctrl+Z again. The shape moved back two steps, skipping over the position change entirely.

The undo stack showed an entry for "X Position" but restoring it had no effect. The before and after states in that entry were identical: both showed X = 250.

The bug was not in the undo system. The undo system restored the before-state correctly. The before-state was simply wrong.

What Was Actually Wrong

HTML input elements update their displayed value immediately when the user types. The change event fires after the user commits (blur or Enter), but the DOM value has been showing the new content since the first keystroke. When captureFullState() reads the input field's value during the change handler, it reads the value the user just typed.

The change event is the right moment to commit the history entry. It is the wrong moment to capture the before-state. The before-state had to be captured earlier: at focus time, before the user touched the field.

A secondary issue: the state snapshot read from the DOM rather than from the editor's internal model. The editor maintained an in-memory representation of the diagram. The property panel inputs were kept in sync with the model, but captureFullState() was including the DOM values of the visible inputs. DOM values change as the user types. Model values change only when the input is committed. The snapshot should have read from the model, not the DOM.

What Got Deleted

The change event before-capture:

// deleted: captures after value is already changed
$('#prop-x, #prop-y').on('change', function() {
    const before = captureFullState(); // wrong
    // ...
});

And the DOM-read path inside captureFullState(). The snapshot function was updated to read exclusively from the editor's internal model, with input field values applied only on commit.

What Replaced It

A before-state is captured on focus of any property input:

$(document).on('focus', '#prop-x, #prop-y, #prop-w, #prop-h, #prop-rotation', () => {
    propBeforeState = captureFullState();
});

$(document).on('change', '#prop-x, #prop-y, #prop-w, #prop-h, #prop-rotation', function() { const before = propBeforeState || captureFullState(); propBeforeState = null; applyPropertyFromInput(this); pushHistory('Property Edit', before, captureFullState()); });

The before-state is captured before the user starts typing. The after-state is captured after the value is applied to the model. These are always different states when the user changed anything.

The Lesson

For any undo entry, "before" means the state before the user started the interaction, not the state at the moment you decide to record it. The start of an interaction and the commit of an interaction are different events. Conflating them produces undo entries that restore the state they came from.

Read this post in the full Engineering Journal →