From 075d1f63e3fee42e8892c69129bf5910a881cb4f Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 16 Aug 2026 21:52:30 +0200 Subject: [PATCH] apply the same render(h(...)) simplification to JobqGraph mara, follow-up on PR#3365: "follow up with the same for jobq". Drops mountJobqGraph the same way mountJobqRollup was dropped - builds.js now calls render(h(JobqGraph, {...}), root) directly, split into mountRebuildQueue() (the one-time replaceChildren() + first render, called once at page init) and renderRebuildQueue() (the render-only path the rebuild_queue_changed handler and the cancel flow reuse, bumping a module-level jobqGraphToken instead of holding a mount handle). The split matters here specifically: repeating replaceChildren() on every refresh would wipe Preact's own tracked children out from under its diffing instead of letting it update them minimally - JobqRollup's simpler version didn't need this since it only ever renders into its own dedicated section once per app lifetime's worth of state, but the rebuild queue refreshes on every `rebuild_queue_changed` tick. Re-verified: npm run build (whole workspace) + swarm-ui typecheck clean (JobqGraph is also used via JSX on swarm-ui's /jobs page, untouched by this), comment-block + issue-ref lints clean, headless- chromium screenshot of builds.html's R3BU1LD QU3U3 tab against a mocked /api/jobq/graph payload - tree, filter checkboxes, and cancel buttons all render identically to before. --- frontend/packages/dashboard/src/builds.js | 43 +++++++++++++------ .../shared/src/jobq-graph/JobqGraph.tsx | 30 +++---------- 2 files changed, 35 insertions(+), 38 deletions(-) diff --git a/frontend/packages/dashboard/src/builds.js b/frontend/packages/dashboard/src/builds.js index 16b37662..7e8f9358 100644 --- a/frontend/packages/dashboard/src/builds.js +++ b/frontend/packages/dashboard/src/builds.js @@ -14,9 +14,10 @@ import { $, fmtAgeSecs, openStream, openBuildLogStream, initServerWarnings } fro import { el } from '@hive/shared/dom.js'; import { bindAsyncForms } from '@hive/shared/forms.js'; import { themedConfirm } from '@hive/shared/modal.js'; +import { h, render } from 'preact'; import { fmtAgo, fmtDuration, truncate } from './util.js'; import '@hive/shared/hive-tab-strip.js'; -import { mountJobqGraph } from '@hive/shared/jobq-graph.js'; +import { JobqGraph } from '@hive/shared/jobq-graph.js'; // ─── derived state ─────────────────────────────────────────────────────────── let metaInputsState = []; @@ -26,7 +27,7 @@ let metaUpdateRunning = false; // the tree itself; this flat array exists only for the two things it // doesn't render: the count-pill and the live-log panel. let jobqNodes = []; -let jobqGraphHandle = null; +let jobqGraphToken = 0; function syncFromSnapshot(s) { metaInputsState = (s.meta_inputs || []).slice(); @@ -127,27 +128,40 @@ function renderMetaInputs(s) { } // ─── rebuild queue ──────────────────────────────────────────────────────────── -// R3BU1LD QU3U3 is `JobqGraph` (Preact, mounted imperatively — see -// mountJobqGraph's own doc comment in @hive/shared/jobq-graph.js for why -// this file has no JSX pipeline) directly — no hand-rolled tree/roll-up -// rendering here anymore. The component owns fetching GET /api/jobq/graph -// and its own refetch (`.refresh()` on the mount handle); this page just -// reads its `onUpdate` callback to keep `jobqNodes` (the flat array) in -// sync for the two things the generic view doesn't render itself: the -// count-pill and the live-log panel below. +// R3BU1LD QU3U3 is `JobqGraph` (Preact, rendered via plain `render(h(...))` +// — no JSX pragma needed for either call, no mount wrapper: `render` is +// already the re-render/diff entry point, per mara on review) directly — +// no hand-rolled tree/roll-up rendering here anymore. The component owns +// fetching GET /api/jobq/graph and its own refetch (bump `jobqGraphToken` +// and render again); this page just reads its `onUpdate` callback to keep +// `jobqNodes` (the flat array) in sync for the two things the generic +// view doesn't render itself: the count-pill and the live-log panel below. // // Cancel is the one action this page *does* wire up: the `cancellable` // prop turns on the component's own per-node cancel button, which calls // `onCancel(id)` rather than posting anything — the endpoint // (`/api/rebuild-queue/{id}/cancel`) is this page's domain concept, not // the generic component's. +// Split from `renderRebuildQueue` below: `replaceChildren()` only +// belongs on the *first* render, to clear the static "loading…" +// placeholder `#rebuild-queue-section` starts with in `builds.html` — +// Preact doesn't know that placeholder is there, so a later render +// call must NOT repeat `replaceChildren()` (it would wipe Preact's own +// tracked children out from under its diffing rather than let it +// update them minimally). function mountRebuildQueue() { const root = $('rebuild-queue-section'); if (!root) return; root.replaceChildren(); - jobqGraphHandle = mountJobqGraph(root, { + renderRebuildQueue(); +} +function renderRebuildQueue() { + const root = $('rebuild-queue-section'); + if (!root) return; + render(h(JobqGraph, { endpoint: '/api/jobq/graph', cancellable: true, + refreshToken: jobqGraphToken, onUpdate: (nodes) => { jobqNodes = nodes || []; renderRebuildLiveLog(); @@ -165,12 +179,12 @@ function mountRebuildQueue() { if (!r.ok) throw new Error('http ' + r.status); // No manual refresh: cancel flips node state, which fires // rebuild_queue_changed over SSE — the existing handler below - // already calls jobqGraphHandle.refresh() on that tick. + // already bumps jobqGraphToken and re-renders on that tick. } catch (err) { console.error('cancel failed', err); } }, - }); + }), root); } // ─── running-rebuild live log ───────────────────────────────────────────────── @@ -471,7 +485,8 @@ const SSE_HANDLERS = { // queue snapshot on the wire (tabs.js/SW4RM still consumes it for the // swarm badges, untouched by this), this page just treats it as a // refetch trigger. `JobqGraph` owns the actual fetch. - if (jobqGraphHandle) jobqGraphHandle.refresh(); + jobqGraphToken += 1; + renderRebuildQueue(); // Auto-refresh build log list when the queue changes and BUILD L0GS is active. if (buildTabs && buildTabs.active() === 'buildlogs') { if (buildRefreshTimer) clearTimeout(buildRefreshTimer); diff --git a/frontend/packages/shared/src/jobq-graph/JobqGraph.tsx b/frontend/packages/shared/src/jobq-graph/JobqGraph.tsx index 3c549f83..8c335ba4 100644 --- a/frontend/packages/shared/src/jobq-graph/JobqGraph.tsx +++ b/frontend/packages/shared/src/jobq-graph/JobqGraph.tsx @@ -17,11 +17,10 @@ // needing the raw list (a count badge, a live-log panel) without its // own parallel fetch. // -// Two ways to use this: JSX (swarm-ui, or any dashboard page that -// renders it directly) — ``. Or imperative mount -// (dashboard/src/builds.js, plain `.js` — mounting needs no JSX pragma) -// — `mountJobqGraph(container, props)` returns `{ refresh(), update(props) }`. +// JSX (swarm-ui) — ``. Or plain `render(h(JobqGraph, +// props), container)` (dashboard/src/builds.js, no JSX pragma needed) +// — call again with a bumped `refreshToken` to refetch. // // Styles live in `@hive/shared/jobq-graph.css`, `@import`ed from a // page/component CSS file rather than imported here — dashboard bundles @@ -29,7 +28,6 @@ // loader is `text` (for unrelated shadow-DOM components' CSS-as-string // needs), and that loader is global per call, not per-module. -import { h, render } from 'preact'; import { useState, useEffect, useCallback } from 'preact/hooks'; // Mirrors `hive_jobq_wire::StateSchema` verbatim (variant names, no @@ -233,8 +231,8 @@ export interface JobqGraphProps { } // `refreshToken` is not read anywhere in the body — its only job is to -// change identity so the effect below re-runs, giving a host (or -// `mountJobqGraph`) an explicit "refetch now" lever without an +// change identity so the effect below re-runs, giving a host an +// explicit "refetch now" lever (bump it and re-render) without an // imperative ref into this component. export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, refreshToken = 0 }: JobqGraphProps) { const [selectedStates, setSelectedStates] = useState>( @@ -294,19 +292,3 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r ); } - -// Imperative mount helper for a call site that isn't itself a JSX file -// (dashboard/src/builds.js — plain `.js`, no per-file JSX pragma to -// write `` inline). Returns a handle: `.refresh()` -// (re-fetch with the current props) and `.update(props)` (merge new -// props — e.g. a different `endpoint` — and re-render). -export function mountJobqGraph(container: Element, initialProps: JobqGraphProps) { - let props = initialProps; - let token = 0; - const draw = () => render(h(JobqGraph, { ...props, refreshToken: token }), container); - draw(); - return { - refresh() { token += 1; draw(); }, - update(next: Partial) { props = { ...props, ...next }; draw(); }, - }; -}