Pitfall Persistence dakka

Mascot Bar Destroy Rebuild Races Playwright

pitfalldakkafrontendtesting

What Happened

Dakka’s Mork dashboard mascot bar (crates/ui/static/js/sidebar.js) rebuilt every agent card on every WebSocket snapshot. The original MascotBar.update():

// Old
this.clusterA.querySelectorAll('.agent-card').forEach(el => el.remove());
boys.forEach((boy, i) => {
  const card = this._createAgentCard(boy, ...);
  this.clusterA.appendChild(card);
  // AvatarManager.renderIntoIconBox: mounts a fresh <canvas> every time
});

Boys go through gearing_up → scheming → scouting → building → … with a boy:snapshot message per transition. Each message wiped both clusters and re-created the cards. Consequences:

  1. Playwright locators raced the rebuild. waitForSelector('.agent-card', { state: 'attached' }) would resolve to a visible .agent-card node and then fail with Timeout 6000ms exceeded on the same line, because the node was detached between the match and the return. Same pattern for locator.hover() on .mqi-indicator: the indicator lived on the terminal panel, but adjacent MascotBar churn was enough to miss the 50ms hover window.
  2. Avatar canvases remounted on every transition. AvatarManager.renderIntoIconBox calls Dither.createAvatar which is async. A rebuild mid-render tore down the in-progress canvas and started over: visible flicker for users, wasted GPU work.
  3. Click listeners had to be re-bound every time (they were, inside _createAgentCard), multiplying event-listener noise.

Root Cause

The code was ported from a React component (MascotBar.tsx) that used keyed rendering: React’s reconciler made the destroy-rebuild fiction cheap. The vanilla-JS port dropped the key-based diff and kept the “wipe and redraw” mental model, which in the DOM means actual destruction.

This is a generalizable pattern: any component ported from a reactive framework to imperative DOM should preserve node identity across updates, not recreate nodes on every state change. The pitfall shows up whenever a test (or a user) tries to interact with a child whose parent is under continuous update pressure.

Fix

Reconcile by stable ID. In dakka’s case, cards carry data-boy-id:

// New: excerpt from sidebar.js
update(boys, focusedBoyId, theme) {
  // Drop cards whose boy is no longer in the warband
  const keep = new Set(boys.map(b => b.id));
  this.barEl.querySelectorAll('.agent-card').forEach(card => {
    if (!keep.has(card.dataset.boyId)) card.remove();
  });
  // Upsert per boy: create if missing, mutate-in-place if present
  boysA.forEach((boy, i) => this._upsertAgentCard(this.clusterA, boy, ...));
  boysB.forEach((boy, i) => this._upsertAgentCard(this.clusterB, boy, ...));
}

_upsertAgentCard(clusterEl, boy, focusedBoyId, theme, vocab, i, total) {
  let card = this.barEl.querySelector(`.agent-card[data-boy-id="${CSS.escape(boy.id)}"]`);
  const isNew = !card;
  if (isNew) {
    card = this._createAgentCard(boy, focusedBoyId, theme, vocab, i, total);
  } else {
    this._applyCardVisuals(card, boy, focusedBoyId, theme, vocab);
  }
  clusterEl.appendChild(card); // appendChild moves existing nodes
  // Avatar: render only if no canvas yet (covers the retry after manifest race)
}

_applyCardVisuals updates the state badge, focused class, icon-box color treatment, and name: without touching the icon-box’s children (that is where the avatar canvas lives). The emoji fallback is only refreshed when iconBox.querySelector('canvas.avatar-canvas') is falsy, so AvatarManager’s work is preserved.

Secondary fix: avatar manifest race

Once the reconciler stopped re-calling renderIntoIconBox on every update, boys that spawned before AvatarManager.init() resolved never got a canvas: the first render bailed, and no subsequent update triggered a retry if the boy sat in one state. Fixed in crates/ui/static/js/app.js by re-running updateMascotBar() once the manifest fetch resolves:

AvatarManager.init().then(() => {
  if (state.boys.size > 0) updateMascotBar();
});

Result

  • 16/16 Playwright specs went from 0 passing to 14 passing + 2 correctly skipping (the 2 skips are conditional on MQI session data, which bloomnet.db lacks in dev).
  • The destroy-rebuild-race failure class (A1, D2, M1, M5, S1) disappeared.
  • User-visible DOM churn on state transitions dropped to zero.
  • [experiments/dakka/2026-04-23-playwright-e2e-specs](/experiments/dakka/2026-04-23-playwright-e2e-specs): the full session story.
  • [topics/pitfalls/html2canvas-live-dom-mutation](/topics/pitfalls/html2canvas-live-dom-mutation): another DOM-churn-vs-canvas interaction in the same codebase.
  • [topics/pitfalls/avatar-state-drift-three-services](/topics/pitfalls/avatar-state-drift-three-services): related three-service avatar coordination.