Postmortem: We Treated In-Progress Wire State as Purely Visual Until It Broke Topology
TLDR
During wire creation, our editor instantiated wire objects directly inside the centralwires registry so snap and highlight logic could detect active draw paths. However, when users pressed Escape to cancel a draw operation, the cancellation handler removed the wire's SVG element from the DOM but failed to purge the wire object from memory. Subsequent topology runs called getBBox() on the detached DOM node (returning zeros) and created ghost connections at coordinate $(0, 0)$. Sanitizing registries with element.isConnected resolved the leak.
| Incident Milestone | Pre-Fix Behavior | Refactored Self-Healing Model |
|---|---|---|
Draw Cancellation (Escape) | DOM element deleted; Memory entry leaked | cleanupWire() removes both DOM and registry |
| Topology Analysis Pass | Processed detached nodes $\rightarrow$ Ghost links | Pre-filters registry (w.element?.isConnected) |
| Undo / Redo Integrity | Stale memory references persisted | DOM-backed source of truth guarantees parity |
Problem statement: the flaws of un-synchronized dual stores
When a user begins drawing a wire, the editor renders a live wire preview.
To enable mid-draw snapping against existing segments, the editor registered the preview wire directly inside the primary this.wires array upon start-point placement.
The flaw emerged during cancellation: pressing Escape deleted the preview element from the SVG DOM, but left the JavaScript object inside this.wires.
Technical failure mode: ghost connections at the canvas origin
The cancellation handler was incomplete:
// DEPRECATED: Partial cancellation handler
function cancelWire() {
activeWire?.element?.remove(); // Removed SVG element...
activeWire = null;
// FAILED: 'this.wires' array retained the cancelled wire object!
}
When a user added a component or triggered a linting pass, the analyzer iterated this.wires and called element.getBBox(). Calling getBBox() on a detached SVG element returns { x:0, y:0, width:0, height:0 }.
The analyzer evaluated this zero-area bbox as a real wire endpoint located at $(0, 0)$, creating ghost net connections with any component positioned at the origin.
The fix & architecture: unified deletion & DOM-backed filtering
We replaced individual element.remove() calls across key handlers (Escape, pointercancel, Delete) with a single, atomic cleanupWire() function:
// REFACTORED: Atomic wire deletion and registry cleanup
function cleanupWire(wireObj) {
if (!wireObj) return;
// 1. Remove element from SVG DOM wireObj.element?.remove();
// 2. Purge from registry and filter out any detached nodes this.wires = this.wires.filter(w => w !== wireObj && w.element?.isConnected);
if (this.activeWire === wireObj) this.activeWire = null; }
// Defensive Pre-Analysis Pass: Treat DOM as single source of truth function runTopologyAnalysis() { this.wires = this.wires.filter(w => w.element?.isConnected); // ... Execute connectivity graph assembly }
Rule of thumb: In DOM-backed canvas editors, treat the DOM as the ultimate source of truth. Purge memory registries using element.isConnected before executing topological analysis passes.