Engineering Journal
Schema Editor
Schema Editor

The second kind of group: when a new container class breaks selection

2026-07-17

TLDR

Selection logic that walks up the DOM to "the container" hardcodes an assumption about what containers exist. We had symbol groups (.domain-symbol) handled everywhere, then added user-created groups (<g id="group_…">) through a different feature, and every selection code path silently ignored them. Click selected the child, not the group. The lesson: container-ness is a concept, and every new kind of container must be registered with every consumer of that concept, or better, behind one shared resolver.

The assumption that seemed reasonable

The editor's palette places multi-element symbols as <g class="domain-symbol">. Selection code learned early to walk up to that group so clicking a resistor's leg selects the whole resistor:

const symGroup = el.closest('.domain-symbol');
if (symGroup) el = symGroup;

The same walk existed in the marquee's hit-collection and later in a double-click handler. Three call sites, one selector, all consistent. At the time, .domain-symbol was not "one kind of group," it was simply "how grouping works here."

When it failed

Ctrl+G existed too. It came from a different feature (layer management) and wrapped the selection in a plain <g id="group_…"> with no class:

const g = document.createElementNS(NS, 'g');
g.id = group_${Date.now()};

A user marquee-selected several elements, grouped them, then clicked the group, and got an individual child element. The group behaved as if it did not exist, because to the selection walk it did not: closest('.domain-symbol') returned null, so the raw click target won.

What made this durable was that the author-path test passed. Grouping "worked": the group appeared in the layers tree, moved as a unit while still selected, ungrouped correctly. The break only showed after deselecting and clicking again — a step the person who wrote the feature had no reason to take, and the person who used it took immediately.

What was actually wrong

Two features each owned half of a concept. groupSelected() defined what a user group is (a <g> with an id prefix). The selection handlers defined what a container does (absorbs clicks on descendants). Nothing connected them: no shared selector, no class contract, no test asserting "click inside any group selects the group."

The deeper miss: "walk up to the container" appeared three times as an inlined closest() call. Inlined policy is invisible policy — when the definition of container changed, there was no single place whose signature demanded an update.

What got deleted, what replaced it

The three call sites now share the widened selector '.domain-symbol, g[id^="group_"]', and the click path additionally walks to the outermost user group so nested groups select as one unit:

const container = el.closest('.domain-symbol, g[id^="group_"]');
if (container) {
  el = container;
  let p = el.parentElement;
  while (p && p.id !== '_cameraRotGroup') {
    if (p.tagName === 'g' && p.id?.startsWith('group_')) el = p;
    p = p.parentElement;
  }
}

Double-click became the deliberate way in: it pierces the container and selects the individual child, which is the convention Figma and draw.io trained everyone on. Single click = the unit; double click = inside the unit.

The generalizable lesson

When a codebase has a concept that multiple features consume (container, selectable, snappable, lockable), the definition must live in exactly one place, even if that place is just a named selector constant or a resolveSelectionTarget(el) helper. The failure mode of inlining it is not a crash; it is a feature that works in the author's flow and breaks in the user's, which is the most expensive kind of bug to find because everyone who could fix it believes it works.

And when you add a second instance of a concept that previously had one (a second kind of group, a second kind of lock, a second coordinate space), grep for every consumer of the first one before shipping. The second kind is where single-implementation assumptions go to die.

Read this post in the full Engineering Journal →