Postmortem: The Layer Panel Had Its Own Data Model and It Was Always Wrong
TLDR
Our original canvas editor maintained a secondary layer model (layers array, groups map, LayerEntry instances) to drive the layer panel UI. Within weeks, undo operations, group/ungroup commands, external SVG imports, and z-index reordering created chronic state desynchronization. We deleted over 200 lines of complex sync event handlers and replaced them with a single DOM-walking buildPanel() function, eliminating all layer panel bugs permanently.
| Incident Milestone | Secondary Sync Model Behavior | Refactored DOM-Walking Model |
|---|---|---|
| Undo / Redo | Displayed stale pre-undo layer data | Instant parity (Reads restored DOM) |
| SVG Import | Imported items omitted from panel | Automatically includes imported nodes |
| Z-Order Moves | Layer panel order drifted from DOM | Panel order strictly mirrors DOM index |
Problem statement: the chronic desync of dual-state architectures
We originally built the layer panel as a self-contained JS data model:
// DEPRECATED: Secondary Layer Data Model (~200 lines deleted)
class LayerPanelModel {
constructor() {
this.layers = [];
this.groups = new Map();
}
onElementAdded(el) { / ... / } onElementDeleted(id) { / ... / } onElementReordered(id, oldIdx, newIdx) { / ... / } }
The rationale was performance: avoid reading the DOM repeatedly.
In practice, every canvas feature introduced a new desynchronization vector:
- Undo/Redo: Restoring a canvas snapshot updated the SVG DOM directly, bypassing individual
onElementAddedevents and leaving the layer panel displaying stale state. - Group / Ungroup: Grouping elements updated the DOM tree but created orphaned object references in the
groupsmap. - SVG Import: External SVG imports bypassed event listeners entirely.
Technical failure mode: Z-order inversion
The most subtle bug involved z-ordering: the layer model tracked elements in insertion order, whereas canvas commands like "Bring to Front" or "Send to Back" mutated DOM child order.
When users dragged a layer entry to reorder it, the secondary model's index diverged from the true DOM index, causing reordered elements to jump to wrong positions on drop.
The fix & architecture: total deletion of secondary models
We deleted the layers array, groups map, LayerEntry class, and all associated sync event listeners (~200 lines of code).
We replaced them with a single buildPanel() function that walks the SVG DOM directly after canvas operations:
// REFACTORED: Single DOM-walking function
function buildPanel(contentRoot, panelContainer) {
panelContainer.innerHTML = '';
Array.from(contentRoot.children) .filter(el => !el.dataset.system) .forEach(el => { const name = el.getAttribute('data-layer-name') || el.id; const isHidden = el.dataset.hidden === 'true'; const isLocked = el.dataset.locked === 'true';
const row = createRowUI(el, name, isHidden, isLocked); panelContainer.appendChild(row); }); }
Rule of thumb: Every synchronization layer added between a UI panel and the underlying DOM scene graph is a bug waiting to happen. Delete the sync layer and derive the UI directly from the DOM tree.