diff --git a/apps/loopover-ui/eslint.config.ts b/apps/loopover-ui/eslint.config.ts index 7bd4696c88..4a12a6bb72 100644 --- a/apps/loopover-ui/eslint.config.ts +++ b/apps/loopover-ui/eslint.config.ts @@ -21,15 +21,16 @@ export default tseslint.config( }, rules: { ...reactHooks.configs.recommended.rules, - // #8608: eslint-plugin-react-hooks 5 -> 7 (forced by the eslint 10 security chain, GHSA-mh99-v99m-4gvg) - // introduced these React-Compiler-era rules, which flag 24 pre-existing files. Fixing setState-in-effect - // patterns is real UI refactoring with behaviour risk -- not something to smuggle into a dependency - // bump -- so exactly the NEW rules are demoted to warn, keeping every rule that existed in v5 at error. - // Follow-up (promote back to error file-by-file): see the issue this comment cites. + // #8608 demoted the four React-Compiler-era rules that arrived with eslint-plugin-react-hooks 5 -> 7 + // because they flagged 24 pre-existing files. #9588 fixed them: purity, refs and static-components are + // now clean and back at error, where the recommended preset puts them. + // + // set-state-in-effect stays at warn for ONE remaining site: docs-toc reads its headings out of the + // rendered DOM. Sourcing them from the compiled MDX `toc` instead is the real fix, but the component + // lives in the docs LAYOUT while that data lives in the child route -- and it also serves docs pages + // that are not MDX at all and so have no compiled toc. That is its own change, tracked in #9872; + // every other call site in this app is already clean. "react-hooks/set-state-in-effect": "warn", - "react-hooks/purity": "warn", - "react-hooks/refs": "warn", - "react-hooks/static-components": "warn", "no-restricted-imports": [ "error", { diff --git a/apps/loopover-ui/src/components/site/animated-terminal.tsx b/apps/loopover-ui/src/components/site/animated-terminal.tsx index 3f637d9a6b..2cdb29f686 100644 --- a/apps/loopover-ui/src/components/site/animated-terminal.tsx +++ b/apps/loopover-ui/src/components/site/animated-terminal.tsx @@ -63,26 +63,30 @@ export function AnimatedTerminal({ }) { const reduce = useReducedMotion(); const [sceneIdx, setSceneIdx] = useState(0); - const [typed, setTyped] = useState(""); - const [showOutput, setShowOutput] = useState(false); + // The typing progress belongs to ONE scene, so it is stored under that scene's key (#9588). Advancing + // the scene therefore resets the animation by derivation -- progress recorded for a previous scene can + // never be read -- instead of two setState calls at the top of the effect. Reduced motion needs no + // state at all: the finished frame is the pure value. + const [progress, setProgress] = useState({ scene: 0, typed: "", showOutput: false }); const scene = scenes[sceneIdx]; + const current = + progress.scene === sceneIdx ? progress : { scene: sceneIdx, typed: "", showOutput: false }; + const typed = reduce ? scene.prompt : current.typed; + const showOutput = reduce ? true : current.showOutput; useEffect(() => { - if (reduce) { - setTyped(scene.prompt); - setShowOutput(true); - return; - } - setTyped(""); - setShowOutput(false); + if (reduce) return; let i = 0; const type = window.setInterval(() => { i += 1; - setTyped(scene.prompt.slice(0, i)); + setProgress({ scene: sceneIdx, typed: scene.prompt.slice(0, i), showOutput: false }); if (i >= scene.prompt.length) { window.clearInterval(type); - window.setTimeout(() => setShowOutput(true), 220); + window.setTimeout( + () => setProgress({ scene: sceneIdx, typed: scene.prompt, showOutput: true }), + 220, + ); } }, TYPE_SPEED); return () => window.clearInterval(type); diff --git a/apps/loopover-ui/src/components/site/api-progress-bar.tsx b/apps/loopover-ui/src/components/site/api-progress-bar.tsx index ae5688c8b7..9f8291ec34 100644 --- a/apps/loopover-ui/src/components/site/api-progress-bar.tsx +++ b/apps/loopover-ui/src/components/site/api-progress-bar.tsx @@ -11,11 +11,20 @@ export function ApiProgressBar() { const active = inFlight > 0 || status === "loading"; const [visible, setVisible] = useState(false); + // Becoming active shows the bar IMMEDIATELY, adjusted during render rather than from an effect (#9588): + // React's documented pattern for reacting to a changed input, and it renders the bar in the same commit + // that saw the change instead of a frame later. + const [wasActive, setWasActive] = useState(active); + if (wasActive !== active) { + setWasActive(active); + if (active) setVisible(true); + } + + // Only the trailing fade-out is a timer, and its setState runs in the timer callback -- not synchronously + // in the effect body. The bar lingers 240ms after the last request settles so a burst of quick calls does + // not strobe it. useEffect(() => { - if (active) { - setVisible(true); - return; - } + if (active) return; const t = window.setTimeout(() => setVisible(false), 240); return () => clearTimeout(t); }, [active]); diff --git a/apps/loopover-ui/src/components/site/api-status-banner.tsx b/apps/loopover-ui/src/components/site/api-status-banner.tsx index 9088f31783..f30af03fc7 100644 --- a/apps/loopover-ui/src/components/site/api-status-banner.tsx +++ b/apps/loopover-ui/src/components/site/api-status-banner.tsx @@ -43,12 +43,9 @@ export function ApiStatusBanner() { const visibleKey = severity ? `${connection}:${status}` : null; const dismissed = visibleKey !== null && dismissedKey === visibleKey; - // If status changes to a new failure mode, un-dismiss. - useEffect(() => { - if (visibleKey && dismissedKey && dismissedKey !== visibleKey) { - setDismissedKey(null); - } - }, [visibleKey, dismissedKey]); + // A new failure mode un-dismisses the banner by DERIVATION: `dismissed` above compares the stored key + // against the current one, so a stale key already reads as not-dismissed (#9588). The effect that used to + // null it out changed no rendered output -- it only scrubbed state nothing consulted. if (!severity || dismissed) return null; diff --git a/apps/loopover-ui/src/components/site/api/try-it.tsx b/apps/loopover-ui/src/components/site/api/try-it.tsx index a7551ea8b0..6cefb3bf67 100644 --- a/apps/loopover-ui/src/components/site/api/try-it.tsx +++ b/apps/loopover-ui/src/components/site/api/try-it.tsx @@ -55,7 +55,11 @@ export function TryIt({ op, server }: { op: OpenApiOperation; server: string }) : "API timing out — actions paused" : null; - const [token, setToken] = useState(""); + // Read once, lazily: localStorage is only touched on the client, and this component is keyed on the + // operation so a switch remounts it and re-reads rather than needing an effect to reset (#9588). + const [token, setToken] = useState(() => + typeof window === "undefined" ? "" : readStoredSessionToken(window.localStorage), + ); const [show, setShow] = useState(false); const [pathParams, setPathParams] = useState>({}); const [queryParams, setQueryParams] = useState>({}); @@ -65,14 +69,6 @@ export function TryIt({ op, server }: { op: OpenApiOperation; server: string }) const [error, setError] = useState(null); const runRef = useRef<() => Promise>(() => Promise.resolve()); - useEffect(() => { - setToken(readStoredSessionToken(localStorage)); - setResult(null); - setError(null); - setPathParams({}); - setQueryParams({}); - }, [op.id]); - const saveToken = (v: string) => { setToken(v); if (v) { diff --git a/apps/loopover-ui/src/components/site/app-panels/activation-preview.test.tsx b/apps/loopover-ui/src/components/site/app-panels/activation-preview.test.tsx index 7bbabfbe4b..276cd63fee 100644 --- a/apps/loopover-ui/src/components/site/app-panels/activation-preview.test.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/activation-preview.test.tsx @@ -1,4 +1,5 @@ -import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; +import { screen, fireEvent, waitFor, within } from "@testing-library/react"; +import { renderWithQueryClient as render } from "@/lib/test-query-client"; import { beforeEach, describe, expect, it, vi } from "vitest"; // Mock the API layer so the component never touches the network. diff --git a/apps/loopover-ui/src/components/site/app-panels/activation-preview.tsx b/apps/loopover-ui/src/components/site/app-panels/activation-preview.tsx index 49e805f2c0..6533c5e07a 100644 --- a/apps/loopover-ui/src/components/site/app-panels/activation-preview.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/activation-preview.tsx @@ -1,5 +1,6 @@ import { CheckCircle2 } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { StatusPill, type Status } from "@/components/site/control-primitives"; import { TableScroll } from "@/components/site/data-table"; @@ -58,49 +59,34 @@ function repoApiBase(repoFullName: string): string | null { export function ActivationPreview({ reviewability }: { reviewability: Array<{ pr: string }> }) { const repoOptions = useMemo(() => extractPreviewRepoOptions(reviewability), [reviewability]); const [repoFullName, setRepoFullName] = useState(repoOptions[0] ?? ""); - const [preview, setPreview] = useState(null); - const [loading, setLoading] = useState(false); - const [loadError, setLoadError] = useState(null); - const base = repoApiBase(repoFullName); const hasRepos = repoOptions.length > 0; - const load = useCallback( - async (opts?: { cancelled?: () => boolean }) => { - const isCancelled = opts?.cancelled ?? (() => false); - const apiBase = repoApiBase(repoFullName); - if (!apiBase) { - setPreview(null); - setLoadError(null); - return; - } - setLoadError(null); - setLoading(true); - const result = await apiFetch(`${apiBase}/activation-preview`, { + // react-query rather than a hand-rolled load effect (#9588): keying on the repo is what drops a + // response that a newer repo selection has already superseded, which the `cancelled` callback this + // replaces did by hand. Options are pinned to match the previous behaviour exactly -- one attempt, no + // refetch on focus, nothing cached across mounts. + const query = useQuery({ + queryKey: ["activation-preview", base], + enabled: base !== null && base !== "", + retry: false, + refetchOnWindowFocus: false, + gcTime: 0, + queryFn: async () => { + const result = await apiFetch(`${base}/activation-preview`, { label: "Activation preview", credentials: "include", silentStatus: true, }); - // Ignore responses after a newer repoFullName keyed a fresh load (#7784). - if (isCancelled()) return; - if (result.ok) { - setPreview(result.data); - } else { - setPreview(null); - setLoadError(result.message); - } - setLoading(false); + if (!result.ok) throw new Error(result.message); + return result.data; }, - [repoFullName], - ); + }); - useEffect(() => { - let cancelled = false; - void load({ cancelled: () => cancelled }); - return () => { - cancelled = true; - }; - }, [load]); + const preview = query.data ?? null; + const loading = query.isFetching; + const loadError = query.isError ? query.error.message : null; + const load = () => void query.refetch(); return (
(null); const [busy, setBusy] = useState(false); - const [loading, setLoading] = useState(false); const [message, setMessage] = useState(null); const base = repoApiBase(repoFullName); const hasRepos = repoOptions.length > 0; - const load = useCallback( - async (opts?: { cancelled?: () => boolean }) => { - const isCancelled = opts?.cancelled ?? (() => false); - const apiBase = repoApiBase(repoFullName); - if (!apiBase) return; - setMessage(null); - setLoading(true); + // react-query rather than a hand-rolled load effect (#9588): keying on the repo drops a response a newer + // selection has already superseded, which the `cancelled` callback this replaces did by hand. Options are + // pinned to the previous behaviour -- one attempt, no focus refetch, nothing cached across mounts. + const query = useQuery({ + queryKey: ["ai-review-settings", base], + enabled: base !== null && base !== "", + retry: false, + refetchOnWindowFocus: false, + gcTime: 0, + queryFn: async () => { const [settings, key] = await Promise.all([ - apiFetch(`${apiBase}/settings`, { + apiFetch(`${base}/settings`, { label: "AI review settings", credentials: "include", silentStatus: true, }), - apiFetch(`${apiBase}/ai-key`, { + apiFetch(`${base}/ai-key`, { label: "AI key status", credentials: "include", silentStatus: true, }), ]); - // Ignore responses after a newer repoFullName keyed a fresh load (#7784). - if (isCancelled()) return; - if (settings.ok) { - setMode(settings.data.aiReviewMode ?? "off"); - setByok(settings.data.aiReviewByok ?? false); - setProvider(settings.data.aiReviewProvider ?? "anthropic"); - setModel(settings.data.aiReviewModel ?? ""); - } - setKeyStatus(key.ok ? key.data : null); - setLoading(false); + return { settings: settings.ok ? settings.data : null, key: key.ok ? key.data : null }; }, - [repoFullName], - ); + }); + + const loading = query.isFetching; + const load = () => void query.refetch(); - useEffect(() => { - let cancelled = false; - void load({ cancelled: () => cancelled }); - return () => { - cancelled = true; - }; - }, [load]); + // Seed the editable fields from whatever the last response carried. Adjusted during render (React's + // documented pattern) and gated on `dataUpdatedAt`, so a fresh response re-seeds but a user's own edits + // survive every re-render in between. + const [seededAt, setSeededAt] = useState(null); + if (query.data && seededAt !== query.dataUpdatedAt) { + setSeededAt(query.dataUpdatedAt); + const settings = query.data.settings; + if (settings) { + setMode(settings.aiReviewMode ?? "off"); + setByok(settings.aiReviewByok ?? false); + setProvider(settings.aiReviewProvider ?? "anthropic"); + setModel(settings.aiReviewModel ?? ""); + } + setKeyStatus(query.data.key); + } async function saveKey() { if (!base) return; diff --git a/apps/loopover-ui/src/components/site/app-panels/ams-miner-cohort-card.test.tsx b/apps/loopover-ui/src/components/site/app-panels/ams-miner-cohort-card.test.tsx index 621b2aa536..3003e3490c 100644 --- a/apps/loopover-ui/src/components/site/app-panels/ams-miner-cohort-card.test.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/ams-miner-cohort-card.test.tsx @@ -1,4 +1,5 @@ -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { screen, fireEvent, waitFor } from "@testing-library/react"; +import { renderWithQueryClient as render } from "@/lib/test-query-client"; import { beforeEach, describe, expect, it, vi } from "vitest"; // Mock the API layer so the component never touches the network. diff --git a/apps/loopover-ui/src/components/site/app-panels/ams-miner-cohort-card.tsx b/apps/loopover-ui/src/components/site/app-panels/ams-miner-cohort-card.tsx index 798da07c81..7fcf79667e 100644 --- a/apps/loopover-ui/src/components/site/app-panels/ams-miner-cohort-card.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/ams-miner-cohort-card.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useState } from "react"; +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { StateBoundary } from "@/components/site/state-views"; import { apiFetch } from "@/lib/api/request"; @@ -91,49 +92,33 @@ function CohortColumn({ title, metrics }: { title: string; metrics: AmsMinerCoho export function AmsMinerCohortCard({ reviewability }: { reviewability: Array<{ pr: string }> }) { const repoOptions = extractPreviewRepoOptions(reviewability); const [repoFullName, setRepoFullName] = useState(repoOptions[0] ?? ""); - const [comparison, setComparison] = useState(null); - const [loading, setLoading] = useState(false); - const [loadError, setLoadError] = useState(null); - const base = repoApiBase(repoFullName); const hasRepos = repoOptions.length > 0; - const load = useCallback( - async (opts?: { cancelled?: () => boolean }) => { - const isCancelled = opts?.cancelled ?? (() => false); - const apiBase = repoApiBase(repoFullName); - if (!apiBase) { - setComparison(null); - setLoadError(null); - return; - } - setLoadError(null); - setLoading(true); - const result = await apiFetch(`${apiBase}/ams-miner-cohort`, { + // react-query rather than a hand-rolled load effect (#9588): the query key drops a response a newer repo + // selection has already superseded, which the `cancelled` callback this replaces did by hand. Options are + // pinned to the previous behaviour -- one attempt, no focus refetch, nothing cached across mounts. + const query = useQuery({ + queryKey: ["ams-miner-cohort", base], + enabled: base !== null && base !== "", + retry: false, + refetchOnWindowFocus: false, + gcTime: 0, + queryFn: async () => { + const result = await apiFetch(`${base}/ams-miner-cohort`, { label: "AMS miner cohort comparison", credentials: "include", silentStatus: true, }); - // Ignore responses after a newer repoFullName keyed a fresh load (#7784). - if (isCancelled()) return; - if (result.ok) { - setComparison(result.data); - } else { - setComparison(null); - setLoadError(result.message); - } - setLoading(false); + if (!result.ok) throw new Error(result.message); + return result.data; }, - [repoFullName], - ); + }); - useEffect(() => { - let cancelled = false; - void load({ cancelled: () => cancelled }); - return () => { - cancelled = true; - }; - }, [load]); + const comparison = query.data ?? null; + const loading = query.isFetching; + const loadError = query.isError ? query.error.message : null; + const load = () => void query.refetch(); return (
reviewability.filter((row) => row.chatQaEnabled), [reviewability]); - const [selectedPr, setSelectedPr] = useState(eligible[0]?.pr ?? ""); + // The chosen PR, or the first eligible one when nothing is chosen yet. DERIVED (#9588) rather than + // back-filled by an effect: the list arrives asynchronously, and the effect used to leave one render + // showing an empty select before correcting itself. + const [chosenPr, setChosenPr] = useState(""); + const selectedPr = chosenPr || (eligible[0]?.pr ?? ""); const [question, setQuestion] = useState(""); const [result, setResult] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); - useEffect(() => { - if (!selectedPr && eligible[0]) setSelectedPr(eligible[0].pr); - }, [selectedPr, eligible]); - if (eligible.length === 0) return null; async function ask() { @@ -98,7 +98,7 @@ export function ChatQaPanel({ reviewability }: { reviewability: ReviewabilityRow