Engineering Journal
Schema Editor
Schema Editor

Your Undo Stack Already Knows When to Revalidate. Stop Wiring It Per Feature.

2026-07-17

TLDR

If your app runs analysis that must stay current with edits, connectivity checks, linting, validation, hook it into the undo history push instead of sprinkling refresh calls through every editing feature. Every meaningful mutation already funnels through history, including features added later by someone who has never heard of your analyzer.


The position

The single integration point for "something changed, recompute" is the history stack, and choosing any other point is choosing to play whack-a-mole.

What everyone does instead

The standard pattern is per-feature invalidation. The place-symbol function calls refreshAnalysis(). The wire tool calls it on commit. Then someone adds paste, and paste does not call it. Then delete, rotate, a group operation, an import path. Each one is a fresh opportunity to forget.

The result is analysis that is current after some edits and stale after others, which is worse than openly stale. A report that is usually right teaches users to trust it exactly until the moment it burns them. We had precisely this: placement refreshed the electrical graph, moving a component did not, so the rule checker's accuracy depended on which gesture the user happened to perform last.

Why per-feature invalidation fails structurally

It couples two things that evolve at different speeds. Editing features multiply, analysis hooks do not multiply with them, and there is no compiler error for the missing call. The failure is silent and discovered by a user staring at a wrong report.

It also inverts the knowledge. The wire tool should not need to know an electrical rule checker exists. Under per-feature invalidation, every tool author must know about every analyzer, forever.

The better approach

Every undoable app already has the funnel: each completed mutation pushes a history entry, because that is what makes it undoable. Mutations that matter to analysis and mutations that matter to undo are almost exactly the same set. So subscribe there:

pushHistory(label, before, after) {
    // ...store the entry...
    this._scheduleAnalysis?.();   // debounced; no-op outside relevant modes
}

The scheduler debounces, skips cheap cases, and defers while a gesture is mid-flight so the rebuild never yanks DOM out from under an active drag:

_scheduleAnalysis() {
    clearTimeout(this._timer);
    this._timer = setTimeout(() => {
        if (this._gestureActive) { this._scheduleAnalysis(); return; }
        this.runAnalysis();
    }, 600);
}

One hook. Draw, move, delete, paste, rotate, group, all covered. The feature someone writes next year is covered on the day it correctly pushes history, which it must do anyway to be undoable. Undo and redo restore paths trigger their own rebuild, closing the loop.

What you give up

Granularity. The history push tells you something changed, not what. A dependency-tracked system could recompute one net instead of the whole graph. If your analysis is expensive enough to need incremental recomputation, the history hook still works as the trigger, but you will layer dirty-tracking on top.

You also inherit the history system's blind spots. Anything mutating state without pushing history, a rogue direct DOM write, silently skips analysis too. In practice that code was already breaking undo, so it was already a bug with a louder symptom.

When per-feature wiring is right

When the reaction is feature-specific rather than a global invariant. A tooltip that follows the wire you just drew belongs in the wire tool. And apps without undo have no funnel to hook, though an editor without undo has bigger problems than stale analysis.

For everything shaped like "the report must reflect the document," the document's own edit log is the subscription point. It was there the whole time.

Read this post in the full Engineering Journal →