diff --git a/packages/web/src/components/dashboard/ServerWorkspaceRows.tsx b/packages/web/src/components/dashboard/ServerWorkspaceRows.tsx index 4b90372..7204d5b 100644 --- a/packages/web/src/components/dashboard/ServerWorkspaceRows.tsx +++ b/packages/web/src/components/dashboard/ServerWorkspaceRows.tsx @@ -1,7 +1,7 @@ import { useQueries } from "@tanstack/react-query"; import { motion } from "framer-motion"; import { ChevronRight, CircleDot } from "lucide-react"; -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo } from "react"; import { scopedConclusionsCountOptions, scopedQueueStatusOptions, @@ -28,16 +28,6 @@ interface Props { const WORKSPACE_PAGE_SIZE = 100; -function metricsEqual(a: FleetRowMetrics, b: FleetRowMetrics): boolean { - return ( - a.workspaceCount === b.workspaceCount && - a.conclusionCount === b.conclusionCount && - a.queueActive === b.queueActive && - a.queuePending === b.queuePending && - a.health === b.health - ); -} - /** * Renders one `` per workspace on a single server (instance), labelled * ` ()`, and reports the server's summed metrics up so the @@ -80,24 +70,19 @@ export function ServerWorkspaceRows({ instance, onOpenWorkspace, onMetrics }: Pr : workspacesQ.isSuccess ? "ok" : "loading"; - const metrics: FleetRowMetrics = useMemo( - () => ({ + // Dep array uses primitives only — an object dep (e.g. the metrics shape) would + // create a new reference on each render even when values are unchanged, causing + // onMetrics → setMetricsById → re-render → new object → onMetrics … loop. + useEffect(() => { + onMetrics(instance.id, { workspaceCount: totalWorkspaces, conclusionCount, queueActive, queuePending, lastSeen: null, health, - }), - [totalWorkspaces, conclusionCount, queueActive, queuePending, health], - ); - - const lastReported = useRef(null); - useEffect(() => { - if (lastReported.current && metricsEqual(lastReported.current, metrics)) return; - lastReported.current = metrics; - onMetrics(instance.id, metrics); - }, [instance.id, metrics, onMetrics]); + }); + }, [instance.id, totalWorkspaces, conclusionCount, queueActive, queuePending, health, onMetrics]); if (workspacesQ.isError) { return ( diff --git a/packages/web/src/test/server-workspace-rows.test.tsx b/packages/web/src/test/server-workspace-rows.test.tsx new file mode 100644 index 0000000..a131d02 --- /dev/null +++ b/packages/web/src/test/server-workspace-rows.test.tsx @@ -0,0 +1,94 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, render, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ServerWorkspaceRows } from "@/components/dashboard/ServerWorkspaceRows"; +import { DemoProvider } from "@/context/DemoContext"; +import type { Instance } from "@/lib/config"; + +vi.mock("@/lib/http", () => ({ + httpFetch: vi.fn(async (input: Request | string) => { + const url = typeof input === "string" ? input : input.url; + const json = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + if (url.includes("/queue/status")) { + return json({ in_progress_work_units: 0, pending_work_units: 0 }); + } + if (url.includes("/conclusions/list")) { + return json({ items: [], total: 3, page: 1, size: 1, pages: 1 }); + } + return json({ items: [{ id: "ws-1" }], total: 1, page: 1, size: 100, pages: 1 }); + }), +})); + +const neo: Instance = { id: "neo", name: "Neo", baseUrl: "https://neo.example.net", token: "" }; + +function makeQc() { + return new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); +} + +function renderRows(instance: Instance, onMetrics: ReturnType) { + const qc = makeQc(); + return render( + + + + + + +
+
+
, + ); +} + +describe("ServerWorkspaceRows — onMetrics stability", () => { + afterEach(() => localStorage.clear()); + + it("calls onMetrics with health:ok after data loads", async () => { + const onMetrics = vi.fn(); + renderRows(neo, onMetrics); + await waitFor(() => + expect(onMetrics).toHaveBeenCalledWith( + "neo", + expect.objectContaining({ health: "ok", workspaceCount: 1, conclusionCount: 3 }), + ), + ); + }); + + it("does not call onMetrics again when values have not changed", async () => { + const onMetrics = vi.fn(); + renderRows(neo, onMetrics); + + // Wait until we have at least one call with stable state + await waitFor(() => + expect(onMetrics).toHaveBeenCalledWith("neo", expect.objectContaining({ health: "ok" })), + ); + + const callsBefore = onMetrics.mock.calls.length; + + // Flush any pending micro-tasks / React batched updates + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + // onMetrics must not have been called again — no render loop + expect(onMetrics).toHaveBeenCalledTimes(callsBefore); + }); + + it("calls onMetrics when health transitions from loading to ok", async () => { + const onMetrics = vi.fn(); + renderRows(neo, onMetrics); + + // Must eventually report ok (not just loading) + await waitFor(() => + expect(onMetrics).toHaveBeenCalledWith("neo", expect.objectContaining({ health: "ok" })), + ); + }); +});