Mascot Bar Destroy Rebuild Races Playwright
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:
- Playwright locators raced the rebuild.
waitForSelector('.agent-card', { state: 'attached' })would resolve to a visible.agent-cardnode and then fail withTimeout 6000ms exceededon the same line, because the node was detached between the match and the return. Same pattern forlocator.hover()on.mqi-indicator: the indicator lived on the terminal panel, but adjacent MascotBar churn was enough to miss the 50ms hover window. - Avatar canvases remounted on every transition.
AvatarManager.renderIntoIconBoxcallsDither.createAvatarwhich is async. A rebuild mid-render tore down the in-progress canvas and started over: visible flicker for users, wasted GPU work. - 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.
Related
[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.