Engineering Journal
Schema Editor
Schema Editor

Postmortem: We Treated In-Progress Wire State as Purely Visual Until It Broke Topology

2026-06-04

TLDR

In-progress wires were added to the wire registry early (needed for snap and highlight). Cancellation removed the SVG element but not the registry entry. The topology analyzer reported connected wires that did not exist visually. The fix: filter the registry by element.isConnected on every cleanup, not just on explicit delete.


The Assumption That Seemed Reasonable

Wire drawing is a two-phase operation: the user places a start point, moves the cursor through one or more intermediate points, and clicks an endpoint to complete. During drawing, the in-progress wire is a visual preview that should not affect the diagram until it is committed.

The natural implementation: create the wire object and its SVG element when the user places the start point. Render the wire live as the cursor moves. Commit to the diagram state when the user places the endpoint. Discard on Escape.

The assumption: the in-progress wire does not affect anything until it is committed. It is purely visual.

This was wrong. The in-progress wire was added to the wire registry immediately on creation, because the snap algorithm needed to see existing wires to prevent snapping to the wire being drawn, and the highlight algorithm needed to trace connected paths including the current wire. Both needed access to the registry.

When It Failed

A user placed a wire start point, drew two segments, and pressed Escape to cancel. The wire's SVG element was removed from the DOM. The visual state was clean. But the wire object remained in the registry array.

On the next topology analysis (triggered by adding a component), the analyzer iterated the wire registry and encountered the cancelled wire. The wire's element property pointed to a detached DOM node. getBBox() on a detached SVG element returns a zero-area bbox. The analyzer reported the wire as having zero-length endpoints at (0, 0), which happened to fall within the bounding box of a component placed at the origin. A ghost connection was created.

The ghost connection appeared in the BOM as a net connecting two components that the user had never wired together. It persisted across undo operations because undo restored the canvas snapshot but the wire registry was not part of the snapshot.

What Was Actually Wrong

The wire registry and the SVG DOM were two separate state stores that were meant to stay in sync but had no synchronization mechanism. The DOM was cleaned up on cancellation. The registry was not.

The cancellation handler was a single line:

function cancelWire() {
    activeWire?.element?.remove();
    activeWire = null;
}

This removed the element from the DOM. It did not remove the wire from this.wires. The registry held a stale reference.

The analyzer trusted the registry as the source of truth. The DOM was the actual truth. They disagreed.

What Got Deleted

The single-line cancellation handler and every place in the codebase that removed a wire by calling element.remove() without updating the registry. There were three: the explicit cancel, the Escape key handler, and a pointercancel listener added to handle touch interruption.

Each was a slightly different implementation of "remove the wire visually" with no corresponding registry cleanup.

What Replaced It

A single _cleanupWire function that is the only way to remove a wire from either the DOM or the registry:

function cleanupWire(wireObj) {
    wireObj?.element?.remove();
    this.wires = this.wires.filter(
        w => w !== wireObj && w.element?.isConnected
    );
    if (this.activeWire === wireObj) this.activeWire = null;
}

The filter w.element?.isConnected acts as a safety net: any wire whose element is no longer in the DOM is purged from the registry regardless of how it got there. This handles the case where an element is removed by an operation that bypasses cleanupWire.

The analyzer no longer trusts the registry as an authoritative list. Before every analysis pass, it filters the registry: this.wires = this.wires.filter(w => w.element?.isConnected). The DOM is the source of truth.

The Lesson

When two data stores are meant to represent the same set of objects, make one the source of truth and derive the other from it. If that is not possible, every mutation to either store must update both atomically. A cleanup function that updates only one is a partial delete.

Read this post in the full Engineering Journal →