From 259a76bb7eadb581f091278acb418c09fc942e94 Mon Sep 17 00:00:00 2001 From: "Matt (via Claude Code)" Date: Sun, 19 Apr 2026 13:26:37 -0500 Subject: [PATCH] =?UTF-8?q?refactor:=20wave=204=20=E2=80=94=20frontend=20A?= =?UTF-8?q?PI=20layer=20(TanStack=20Query=20+=20typed=20hooks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a proper API layer so no component has to call fetch() directly and no data-fetching useEffect has to be hand-rolled. See docs/clean-code.md §8. New infrastructure ------------------ - src/api/client.ts: typed fetch wrapper (ApiError with status + detail, JSON / text / blob variants, FastAPI `detail` surfacing). - src/api/hooks/runs.ts: typed React Query hooks for the run-detail endpoint family — useRun, useRunReport, useRunDimensions, useRunLineage, useRunSkillMd, useFamilyVariants, useBenchFamily. Every hook keyed consistently (["run", id, ...]) so cache dedup and invalidation work cleanly. - src/main.tsx: QueryClientProvider with SKLD-tuned defaults — retry=1, refetchOnWindowFocus=false, staleTime=30s. We don't need aggressive background refetch because WebSockets are the real-time source of truth. Exemplar conversion ------------------- AtomicRunDetail.tsx — the worst fetch-hotspot in the codebase. Before: 738 LOC, 7 useEffect, 9 useState, 6 raw fetch() calls. After: 625 LOC, 0 useEffect, 1 useState, 0 raw fetch calls. All data-fetching replaced with hook calls. Manual loading/error/retry state removed. Component now only owns UI state (the active tab). Other 18 components with raw fetch() calls are deferred — the pattern is established and each can be converted as part of Wave 5 (frontend hotspot decomposition) when each component is already being opened. QA -- frontend lint - clean frontend format:check - clean frontend build - passes frontend vitest - 35/35 pass backend untouched - still green Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/package-lock.json | 39 +++++- frontend/package.json | 4 +- frontend/src/api/client.ts | 82 +++++++++++ frontend/src/api/hooks/runs.ts | 83 +++++++++++ frontend/src/components/AtomicRunDetail.tsx | 145 +++++--------------- frontend/src/main.tsx | 22 ++- 6 files changed, 265 insertions(+), 110 deletions(-) create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/hooks/runs.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5f7ccf0..4fb888c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "skillforge-frontend", "version": "0.1.0", "dependencies": { + "@tanstack/react-query": "^5.99.2", "@types/diff": "^7.0.2", "diff": "^8.0.4", "prism-react-renderer": "^2.4.1", @@ -16,7 +17,8 @@ "react-markdown": "^10.1.0", "react-router-dom": "^7.14.0", "recharts": "^2.13.0", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "zod": "^4.3.6" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -1579,6 +1581,32 @@ "win32" ] }, + "node_modules/@tanstack/query-core": { + "version": "5.99.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.2.tgz", + "integrity": "sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.99.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.2.tgz", + "integrity": "sha512-vM91UEe45QUS9ED6OklsVL15i8qKcRqNwpWzPTVWvRPRSEgDudDgHpvyTjcdlwHcrKNa80T+xXYcchT2noPnZA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.99.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -7392,6 +7420,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index dbd9de4..82e5ba8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "typecheck": "tsc -b --noEmit" }, "dependencies": { + "@tanstack/react-query": "^5.99.2", "@types/diff": "^7.0.2", "diff": "^8.0.4", "prism-react-renderer": "^2.4.1", @@ -23,7 +24,8 @@ "react-markdown": "^10.1.0", "react-router-dom": "^7.14.0", "recharts": "^2.13.0", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "zod": "^4.3.6" }, "devDependencies": { "@eslint/js": "^9.17.0", diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..f4c535d --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,82 @@ +/** + * Typed fetch wrapper for the SKLD backend. + * + * One place for: base URL resolution, default headers, error shaping, + * response parsing. Every API hook in `src/api/hooks/` runs through this. + * No component should ever call `fetch` directly — see docs/clean-code.md §8. + */ + +export class ApiError extends Error { + constructor( + public readonly status: number, + public readonly url: string, + message: string, + ) { + super(message); + this.name = "ApiError"; + } +} + +type RequestInitExt = Omit & { + body?: unknown; +}; + +async function request(path: string, init: RequestInitExt = {}): Promise { + const { body, headers, ...rest } = init; + const isJson = body !== undefined && !(body instanceof FormData); + const response = await fetch(path, { + ...rest, + headers: { + ...(isJson ? { "Content-Type": "application/json" } : {}), + ...headers, + }, + body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body), + }); + + if (!response.ok) { + // Try to surface the FastAPI `detail` field when present. + let detail: string | undefined; + try { + const payload = (await response.json()) as { detail?: string }; + detail = payload?.detail; + } catch { + // Body wasn't JSON — fall through to generic message. + } + throw new ApiError( + response.status, + path, + detail ?? `${response.status} ${response.statusText}`, + ); + } + + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + return (await response.json()) as T; + } + // Callers that want bytes / text must use `requestText` / `requestBlob`. + return (await response.text()) as unknown as T; +} + +async function requestText(path: string, init: RequestInitExt = {}): Promise { + const response = await fetch(path, init as RequestInit); + if (!response.ok) { + throw new ApiError(response.status, path, `${response.status} ${response.statusText}`); + } + return response.text(); +} + +async function requestBlob(path: string, init: RequestInitExt = {}): Promise { + const response = await fetch(path, init as RequestInit); + if (!response.ok) { + throw new ApiError(response.status, path, `${response.status} ${response.statusText}`); + } + return response.blob(); +} + +export const apiClient = { + get: (path: string) => request(path, { method: "GET" }), + getText: (path: string) => requestText(path, { method: "GET" }), + getBlob: (path: string) => requestBlob(path, { method: "GET" }), + post: (path: string, body?: unknown) => request(path, { method: "POST", body }), + delete: (path: string) => request(path, { method: "DELETE" }), +}; diff --git a/frontend/src/api/hooks/runs.ts b/frontend/src/api/hooks/runs.ts new file mode 100644 index 0000000..4596a4a --- /dev/null +++ b/frontend/src/api/hooks/runs.ts @@ -0,0 +1,83 @@ +/** + * React Query hooks for /api/runs/* endpoints. + * + * One hook per endpoint. See docs/clean-code.md §8 — no raw fetch() + * in components, no manual useEffect chains for data-fetching, no + * custom loading/error/retry logic. React Query owns all of that. + */ + +import { useQuery } from "@tanstack/react-query"; + +import { apiClient } from "@/api/client"; +import type { + BenchFamilyDetail, + DimensionStatus, + LineageEdge, + LineageNode, + RunDetail, + RunReport, + Variant, +} from "@/types"; + +interface LineagePayload { + nodes: LineageNode[]; + edges: LineageEdge[]; +} + +const runKey = (runId: string) => ["run", runId] as const; + +export function useRun(runId: string | null) { + return useQuery({ + queryKey: runKey(runId ?? "").concat("detail") as readonly unknown[], + queryFn: () => apiClient.get(`/api/runs/${runId}`), + enabled: !!runId, + }); +} + +export function useRunReport(runId: string | null) { + return useQuery({ + queryKey: [...runKey(runId ?? ""), "report"], + queryFn: () => apiClient.get(`/api/runs/${runId}/report`), + enabled: !!runId, + }); +} + +export function useRunDimensions(runId: string | null) { + return useQuery({ + queryKey: [...runKey(runId ?? ""), "dimensions"], + queryFn: () => apiClient.get(`/api/runs/${runId}/dimensions`), + enabled: !!runId, + }); +} + +export function useRunLineage(runId: string | null) { + return useQuery({ + queryKey: [...runKey(runId ?? ""), "lineage"], + queryFn: () => apiClient.get(`/api/runs/${runId}/lineage`), + enabled: !!runId, + }); +} + +export function useRunSkillMd(runId: string | null) { + return useQuery({ + queryKey: [...runKey(runId ?? ""), "skill_md"], + queryFn: () => apiClient.getText(`/api/runs/${runId}/export?format=skill_md`), + enabled: !!runId, + }); +} + +export function useFamilyVariants(familyId: string | null | undefined) { + return useQuery({ + queryKey: ["family", familyId, "variants"] as const, + queryFn: () => apiClient.get(`/api/families/${familyId}/variants`), + enabled: !!familyId, + }); +} + +export function useBenchFamily(slug: string | null | undefined) { + return useQuery({ + queryKey: ["bench", slug] as const, + queryFn: () => apiClient.get(`/api/bench/${slug}`), + enabled: !!slug, + }); +} diff --git a/frontend/src/components/AtomicRunDetail.tsx b/frontend/src/components/AtomicRunDetail.tsx index df386ea..7cb92a7 100644 --- a/frontend/src/components/AtomicRunDetail.tsx +++ b/frontend/src/components/AtomicRunDetail.tsx @@ -1,6 +1,21 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Link } from "react-router-dom"; +import { + useBenchFamily, + useFamilyVariants, + useRunDimensions, + useRunLineage, + useRunReport, + useRunSkillMd, +} from "@/api/hooks/runs"; +import type { + BenchFamilyDetail, + CompetitionScoresPayload, + DimensionStatus, + RunDetail, +} from "@/types"; + import AtomicLineageView from "./AtomicLineageView"; import ChallengeGallery from "./ChallengeGallery"; import CompetitionBracket from "./CompetitionBracket"; @@ -13,17 +28,6 @@ import PerDimensionFitnessBar from "./PerDimensionFitnessBar"; import PipelineOverview from "./PipelineOverview"; import PrimaryButton from "./PrimaryButton"; import RunNarrative from "./RunNarrative"; -import type { - BenchChallenge, - BenchFamilyDetail, - CompetitionScoresPayload, - DimensionStatus, - LineageEdge, - LineageNode, - RunDetail, - RunReport, - Variant, -} from "../types"; interface AtomicRunDetailProps { runId: string; @@ -73,113 +77,40 @@ export default function AtomicRunDetail({ runDetail, dimensions: dimensionsProp, }: AtomicRunDetailProps) { - const [report, setReport] = useState(null); - const [reportError, setReportError] = useState(null); - const [variants, setVariants] = useState(null); - const [variantsError, setVariantsError] = useState(null); - const [lineage, setLineage] = useState<{ - nodes: LineageNode[]; - edges: LineageEdge[]; - } | null>(null); - const [skillMd, setSkillMd] = useState(null); - const [skillMdError, setSkillMdError] = useState(null); - const [benchChallenges, setBenchChallenges] = useState([]); - const [benchDetail, setBenchDetail] = useState(null); - const [dimensionsFetched, setDimensionsFetched] = useState([]); const [activeTab, setActiveTab] = useState("dimensions"); - const dims = dimensionsProp && dimensionsProp.length > 0 ? dimensionsProp : dimensionsFetched; - - // Fetch dimensions if not provided via props + // All data-fetching runs through typed React Query hooks. No useEffects, + // no manual loading/error state, no duplicate requests across mounts. const hasPropDims = dimensionsProp != null && dimensionsProp.length > 0; - useEffect(() => { - if (hasPropDims) return; - fetch(`/api/runs/${runId}/dimensions`) - .then((r) => (r.ok ? (r.json() as Promise) : [])) - .then(setDimensionsFetched) - .catch(() => {}); - }, [runId, hasPropDims]); - - // Fetch the report (includes skill_genomes array for atomic runs) - useEffect(() => { - if (!runId) return; - fetch(`/api/runs/${runId}/report`) - .then((r) => { - if (!r.ok) throw new Error(`HTTP ${r.status}`); - return r.json() as Promise; - }) - .then(setReport) - .catch((err) => setReportError(String(err))); - }, [runId]); - - // Fetch the variants list - useEffect(() => { - const familyId = runDetail.family_id; - if (!familyId) return; - fetch(`/api/families/${familyId}/variants`) - .then((r) => { - if (!r.ok) throw new Error(`HTTP ${r.status}`); - return r.json() as Promise; - }) - .then(setVariants) - .catch((err) => setVariantsError(String(err))); - }, [runDetail.family_id]); - - // Fetch the lineage graph - useEffect(() => { - if (!runId) return; - fetch(`/api/runs/${runId}/lineage`) - .then((r) => { - if (!r.ok) throw new Error(`HTTP ${r.status}`); - return r.json() as Promise<{ - nodes: LineageNode[]; - edges: LineageEdge[]; - }>; - }) - .then(setLineage) - .catch(() => setLineage({ nodes: [], edges: [] })); - }, [runId]); - - // Fetch the composite SKILL.md body - useEffect(() => { - if (!runId) return; - fetch(`/api/runs/${runId}/export?format=skill_md`) - .then((r) => { - if (!r.ok) throw new Error(`HTTP ${r.status}`); - return r.text(); - }) - .then(setSkillMd) - .catch((err) => setSkillMdError(String(err))); - }, [runId]); - - // Fetch raw baseline scores from the bench API for competition display - useEffect(() => { - const slug = report?.taxonomy?.family_slug; - if (!slug) return; - fetch(`/api/bench/${slug}`) - .then((r) => { - if (!r.ok) return null; - return r.json() as Promise; - }) - .then((data) => { - if (data) { - setBenchDetail(data); - if (data.challenges) setBenchChallenges(data.challenges); - } - }) - .catch(() => {}); - }, [report?.taxonomy?.family_slug]); + const { data: fetchedDims = [] } = useRunDimensions(hasPropDims ? null : runId); + const dims: DimensionStatus[] = hasPropDims ? dimensionsProp! : fetchedDims; + + const { data: report = null, error: reportQueryError } = useRunReport(runId); + const reportError = reportQueryError ? String(reportQueryError) : null; + + const { data: variants = null, error: variantsQueryError } = useFamilyVariants( + runDetail.family_id, + ); + const variantsError = variantsQueryError ? String(variantsQueryError) : null; + + const { data: lineageData } = useRunLineage(runId); + const lineage = lineageData ?? { nodes: [], edges: [] }; + + const { data: skillMd = null, error: skillMdQueryError } = useRunSkillMd(runId); + const skillMdError = skillMdQueryError ? String(skillMdQueryError) : null; + + const { data: benchDetail = null } = useBenchFamily(report?.taxonomy?.family_slug); // Build a lookup map: challenge_id → raw composite score const rawBaselineMap = useMemo(() => { const map: Record = {}; - for (const c of benchChallenges) { + for (const c of benchDetail?.challenges ?? []) { if (c.raw?.composite != null) { map[c.challenge_id] = c.raw.composite; } } return map; - }, [benchChallenges]); + }, [benchDetail?.challenges]); // Hard-coded per the seed run's fitness_summary. The pre-existing seed // variant beat the Spawner alternative on these 3 dimensions out of 12. diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 27481e0..f3fdc53 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,30 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import React from "react"; import ReactDOM from "react-dom/client"; + import App from "./App"; import "./index.css"; +// Defaults tuned for SKLD's live-run UI: the WebSocket channel is the +// real-time source of truth, so we don't need aggressive background +// refetch. Disable window-focus refetch to avoid hammering the API +// whenever the user Cmd-Tabs. Keep retry at 1 — transient network +// blips should not surface as a hard failure state, but a broken +// endpoint shouldn't keep retrying for a minute either. +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + staleTime: 30_000, + }, + }, +}); + ReactDOM.createRoot(document.getElementById("root")!).render( - + + + , );