From 4559a56e2e14db42ba9f1b7eca497cdea7ad7733 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 25 May 2026 01:22:11 +0200 Subject: [PATCH] dashboard: guard renderers against missing-root on /flow.html (#399) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mara hit: TypeError: can't access property "innerHTML", root is null renderContainers app.js:666 applyContainerStateChanged app.js:484 container_state_changed app.js:2306 …on /flow.html. The same bundled `app.js` runs on both /index.html (dashboard, has the tab panes) and /flow.html (flow page, has only the broker terminal). SSE events arrive on every page — `container_state_changed` / `tombstones_changed` / `approval_*` / `question_*` route through their corresponding renderers, which then `root.innerHTML = ''` on `$('section-id')` and crash when the section isn't in the DOM. The convention is already "no-op when the target DOM doesn't exist" — `renderMetaInputs`, `renderRebuildQueue`, `renderReminders` all guard with `if (!root) return;` at the top. Bring the rest in line: - `renderContainers` - `renderTombstones` - `renderQuestions` - `renderApprovals` `renderInbox` already handles the absent case via its `if (root && !root.hidden)` branch — no change. No behaviour change on /index.html. On /flow.html the failing events silently no-op as intended (terminal renderers still re-render the broker tail normally). --- frontend/packages/dashboard/src/app.js | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/frontend/packages/dashboard/src/app.js b/frontend/packages/dashboard/src/app.js index 88f056e..d4fcd6f 100644 --- a/frontend/packages/dashboard/src/app.js +++ b/frontend/packages/dashboard/src/app.js @@ -704,6 +704,15 @@ window.marked = marked; function renderContainers(s) { const root = $('containers-section'); + // #containers-section only exists on /index.html. The same + // bundled app.js runs on /flow.html (which has the broker + // terminal but no container list) and on any future pages — + // `container_state_changed` SSE events arrive on every page and + // route through `applyContainerStateChanged → renderContainersFromState`, + // so without this guard the renderer throws `root is null` on + // every event (#399). Matches the no-op-when-target-absent + // convention the other renderers (renderTombstones, etc.) follow. + if (!root) return; root.innerHTML = ''; // Containers come from the derived map (event-driven) rather than @@ -1042,6 +1051,10 @@ window.marked = marked; function renderTombstones(s) { const root = $('tombstones-section'); + // #tombstones-section only lives on /index.html (SYST3M tab); + // no-op on /flow.html and any other page that loads the shared + // bundle without the dashboard's tab panes (#399). + if (!root) return; root.innerHTML = ''; if (!s.tombstones || !s.tombstones.length) { root.append(el('p', { class: 'empty' }, 'no kept state — clean')); @@ -1176,6 +1189,11 @@ window.marked = marked; } function renderQuestions() { const root = $('questions-section'); + // #questions-section only lives on /index.html (Y3R C4LL tab); + // no-op on /flow.html etc. — the bundled app.js runs everywhere + // and `question_added` / `question_resolved` SSE events route + // through here (#399). + if (!root) return; root.innerHTML = ''; const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); const allPending = questionsState.pending; @@ -1563,6 +1581,11 @@ window.marked = marked; function renderApprovals() { const root = $('approvals-section'); + // #approvals-section only lives on /index.html (Y3R C4LL tab); + // no-op elsewhere — `approval_added` / `approval_resolved` SSE + // events route through here on every page that loads the bundle + // (#399). + if (!root) return; root.innerHTML = ''; // Spawn request form: submitting it queues a Spawn approval that