Engineering Journal
Schema Editor
Schema Editor

Postmortem: The Layer Panel Had Its Own Data Model and It Was Always Wrong

2026-06-04

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 MilestoneSecondary Sync Model BehaviorRefactored DOM-Walking Model
Undo / RedoDisplayed stale pre-undo layer dataInstant parity (Reads restored DOM)
SVG ImportImported items omitted from panelAutomatically includes imported nodes
Z-Order MovesLayer panel order drifted from DOMPanel 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:

  1. Undo/Redo: Restoring a canvas snapshot updated the SVG DOM directly, bypassing individual onElementAdded events and leaving the layer panel displaying stale state.
  2. Group / Ungroup: Grouping elements updated the DOM tree but created orphaned object references in the groups map.
  3. 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.
Read this post in the full Engineering Journal →