From 38aa5f77f4901286c384011dd3b51dc272f47985 Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 1 Aug 2026 01:38:45 +0200 Subject: [PATCH] frontend: guard hive-menu/hive-agent-menu connectedCallback against reconnect Fixes hyperhive#2893: 'Element.attachShadow: Unable to re-attach to existing ShadowDOM', crashing swarm.js's live-update render path. A custom element's connectedCallback fires again on a same-document *move* (insertBefore/append repositioning an already-connected node runs the removal + insertion steps for its whole subtree), not just on a fresh mount. swarm.js's row-fingerprint cache reuses + reorders existing
  • subtrees on live updates -- reordering an unchanged, cached row moves its already-initialised / without ever really detaching it from the document, so connectedCallback re-runs full setup on an instance that's already set up. attachShadow() throws unconditionally if the host already has a shadow root, and HiveAgentMenu's unconditional child-menu creation would have appended a second on top of the first, doubling the dropdown, once the shadow-attach crash itself was out of the way. Both connectedCallbacks now bail early if already initialised (shadowRoot present / _menu already built). Reproduced the crash and duplicate-menu bug with an unguarded control copy of both files driven via headless Chromium (simulating the exact row-reorder move), then confirmed the guarded version throws nothing, keeps the same shadowRoot object identity across the move, and doesn't duplicate the dropdown. --- .../dashboard/src/agent-menu/hive-agent-menu.js | 7 +++++++ frontend/packages/shared/src/hive-menu/hive-menu.js | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/frontend/packages/dashboard/src/agent-menu/hive-agent-menu.js b/frontend/packages/dashboard/src/agent-menu/hive-agent-menu.js index 54fe3b60..bfed9242 100644 --- a/frontend/packages/dashboard/src/agent-menu/hive-agent-menu.js +++ b/frontend/packages/dashboard/src/agent-menu/hive-agent-menu.js @@ -49,6 +49,13 @@ async function agentMenuPost(actionPath, name, body, graceful) { class HiveAgentMenu extends HTMLElement { connectedCallback() { + // Same reconnect-without-detach hazard as (see its + // connectedCallback comment) — swarm.js's row cache can move an + // already-built
  • subtree without a real detach. Without this + // guard a reconnect would append a second on top of the + // first, doubling the dropdown. + if (this._menu) return; + const { c, forgeBase } = this._opts || {}; const btn = el('button', { diff --git a/frontend/packages/shared/src/hive-menu/hive-menu.js b/frontend/packages/shared/src/hive-menu/hive-menu.js index 50d06625..53d8da8b 100644 --- a/frontend/packages/shared/src/hive-menu/hive-menu.js +++ b/frontend/packages/shared/src/hive-menu/hive-menu.js @@ -65,6 +65,16 @@ document.addEventListener('keydown', (e) => { class HiveMenu extends HTMLElement { connectedCallback() { + // A custom element's connectedCallback fires again on a same-document + // *move* (insertBefore/append repositioning an already-connected node + // runs the removal + insertion steps for its whole subtree), not just + // on a fresh mount — swarm.js's row-fingerprint cache reuses/reorders + // existing
  • subtrees, which can move an already-initialised + // without ever detaching it from the document. Guard + // against re-running setup (attachShadow throws if the host already + // has a shadow root) on that reconnect. + if (this.shadowRoot) return; + const { trigger, content } = this._opts || {}; const root = attachShadowCss(this, hiveMenuCss);