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 cc3c66d232..49e805f2c0 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 @@ -65,31 +65,41 @@ export function ActivationPreview({ reviewability }: { reviewability: Array<{ pr const base = repoApiBase(repoFullName); const hasRepos = repoOptions.length > 0; - const load = useCallback(async () => { - const apiBase = repoApiBase(repoFullName); - if (!apiBase) { - setPreview(null); + 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); - return; - } - setLoadError(null); - setLoading(true); - const result = await apiFetch(`${apiBase}/activation-preview`, { - label: "Activation preview", - credentials: "include", - silentStatus: true, - }); - if (result.ok) { - setPreview(result.data); - } else { - setPreview(null); - setLoadError(result.message); - } - setLoading(false); - }, [repoFullName]); + setLoading(true); + const result = await apiFetch(`${apiBase}/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); + }, + [repoFullName], + ); useEffect(() => { - void load(); + let cancelled = false; + void load({ cancelled: () => cancelled }); + return () => { + cancelled = true; + }; }, [load]); return ( @@ -148,8 +158,8 @@ export function ActivationPreview({ reviewability }: { reviewability: Array<{ pr isLoading={Boolean(base) && loading} isError={Boolean(base) && !loading && loadError !== null} isEmpty={Boolean(base) && !loading && preview !== null && preview.evaluatedCount === 0} - onRetry={load} - onRefresh={load} + onRetry={() => void load()} + onRefresh={() => void load()} loadingTitle="Building activation preview…" errorTitle="Couldn't load the activation preview" errorDescription={loadError ?? undefined} diff --git a/apps/loopover-ui/src/components/site/app-panels/ai-review-settings.tsx b/apps/loopover-ui/src/components/site/app-panels/ai-review-settings.tsx index b6c9a193bd..51d579edfd 100644 --- a/apps/loopover-ui/src/components/site/app-panels/ai-review-settings.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/ai-review-settings.tsx @@ -61,35 +61,45 @@ export function AiReviewSettings({ reviewability }: { reviewability: Array<{ pr: const base = repoApiBase(repoFullName); const hasRepos = repoOptions.length > 0; - const load = useCallback(async () => { - const apiBase = repoApiBase(repoFullName); - if (!apiBase) return; - setMessage(null); - setLoading(true); - const [settings, key] = await Promise.all([ - apiFetch(`${apiBase}/settings`, { - label: "AI review settings", - credentials: "include", - silentStatus: true, - }), - apiFetch(`${apiBase}/ai-key`, { - label: "AI key status", - credentials: "include", - silentStatus: true, - }), - ]); - 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); - }, [repoFullName]); + const load = useCallback( + async (opts?: { cancelled?: () => boolean }) => { + const isCancelled = opts?.cancelled ?? (() => false); + const apiBase = repoApiBase(repoFullName); + if (!apiBase) return; + setMessage(null); + setLoading(true); + const [settings, key] = await Promise.all([ + apiFetch(`${apiBase}/settings`, { + label: "AI review settings", + credentials: "include", + silentStatus: true, + }), + apiFetch(`${apiBase}/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); + }, + [repoFullName], + ); useEffect(() => { - void load(); + let cancelled = false; + void load({ cancelled: () => cancelled }); + return () => { + cancelled = true; + }; }, [load]); async function saveKey() { 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 4fe2b8ec67..621b2aa536 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 @@ -139,4 +139,38 @@ describe("AmsMinerCohortCard", () => { }); expect(screen.getByText(/This view is unavailable for this repository\./i)).toBeTruthy(); }); + + it("drops a superseded response when repoFullName changes before the first fetch resolves (#7784)", async () => { + let resolveStale!: (value: unknown) => void; + let resolveFresh!: (value: unknown) => void; + apiFetch + .mockImplementationOnce(() => new Promise((resolve) => (resolveStale = resolve))) + .mockImplementationOnce(() => new Promise((resolve) => (resolveFresh = resolve))); + + render(); + expect(screen.getByText(/Loading AMS contributor mix/i)).toBeTruthy(); + + // Keystroke races a second request while the first is still in flight. + fireEvent.change(screen.getByPlaceholderText("owner/repo"), { + target: { value: "acme/other" }, + }); + await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2)); + + const freshComparison = { + ...POPULATED_COMPARISON, + windowDays: 30, + totalSubmitterCount: 2, + checkedSubmitterCount: 2, + }; + // Newer request resolves first and must win. + resolveFresh({ ok: true, data: freshComparison }); + await waitFor(() => expect(screen.getByText(/Window: 30 days · checked 2 of/i)).toBeTruthy()); + + // Stale request resolves last — must not overwrite the fresh window. + resolveStale({ ok: true, data: POPULATED_COMPARISON }); + await Promise.resolve(); + await Promise.resolve(); + expect(screen.getByText(/Window: 30 days · checked 2 of/i)).toBeTruthy(); + expect(screen.queryByText(/Window: 90 days · checked 5 of/i)).toBeNull(); + }); }); 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 96e290c979..798da07c81 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 @@ -98,31 +98,41 @@ export function AmsMinerCohortCard({ reviewability }: { reviewability: Array<{ p const base = repoApiBase(repoFullName); const hasRepos = repoOptions.length > 0; - const load = useCallback(async () => { - const apiBase = repoApiBase(repoFullName); - if (!apiBase) { - setComparison(null); + 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); - return; - } - setLoadError(null); - setLoading(true); - const result = await apiFetch(`${apiBase}/ams-miner-cohort`, { - label: "AMS miner cohort comparison", - credentials: "include", - silentStatus: true, - }); - if (result.ok) { - setComparison(result.data); - } else { - setComparison(null); - setLoadError(result.message); - } - setLoading(false); - }, [repoFullName]); + setLoading(true); + const result = await apiFetch(`${apiBase}/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); + }, + [repoFullName], + ); useEffect(() => { - void load(); + let cancelled = false; + void load({ cancelled: () => cancelled }); + return () => { + cancelled = true; + }; }, [load]); return ( @@ -171,8 +181,8 @@ export function AmsMinerCohortCard({ reviewability }: { reviewability: Array<{ p isLoading={Boolean(base) && loading} isError={Boolean(base) && !loading && loadError !== null} isEmpty={Boolean(base) && !loading && comparison !== null && !comparison.present} - onRetry={load} - onRefresh={load} + onRetry={() => void load()} + onRefresh={() => void load()} loadingTitle="Loading AMS contributor mix…" errorTitle="Couldn't load the AMS contributor mix" errorDescription={loadError ?? undefined} diff --git a/apps/loopover-ui/src/components/site/app-panels/maintainer-settings.tsx b/apps/loopover-ui/src/components/site/app-panels/maintainer-settings.tsx index ebef4c74af..be2f5fc6e1 100644 --- a/apps/loopover-ui/src/components/site/app-panels/maintainer-settings.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/maintainer-settings.tsx @@ -145,32 +145,42 @@ export function MaintainerSettings({ reviewability }: { reviewability: Array<{ p const base = repoApiBase(repoFullName); const hasRepos = repoOptions.length > 0; - const load = useCallback(async () => { - const apiBase = repoApiBase(repoFullName); - if (!apiBase) return; - setMessage(null); - setLoading(true); - const result = await apiFetch(`${apiBase}/settings`, { - label: "Repository settings", - credentials: "include", - silentStatus: true, - }); - // Default the agent-layer fields defensively so the editor renders even against an older response shape. - setSettings( - result.ok - ? { - ...result.data, - autonomy: result.data.autonomy ?? {}, - agentPaused: result.data.agentPaused ?? false, - agentDryRun: result.data.agentDryRun ?? false, - } - : null, - ); - setLoading(false); - }, [repoFullName]); + const load = useCallback( + async (opts?: { cancelled?: () => boolean }) => { + const isCancelled = opts?.cancelled ?? (() => false); + const apiBase = repoApiBase(repoFullName); + if (!apiBase) return; + setMessage(null); + setLoading(true); + const result = await apiFetch(`${apiBase}/settings`, { + label: "Repository settings", + credentials: "include", + silentStatus: true, + }); + // Ignore responses after a newer repoFullName keyed a fresh load (#7784). + if (isCancelled()) return; + // Default the agent-layer fields defensively so the editor renders even against an older response shape. + setSettings( + result.ok + ? { + ...result.data, + autonomy: result.data.autonomy ?? {}, + agentPaused: result.data.agentPaused ?? false, + agentDryRun: result.data.agentDryRun ?? false, + } + : null, + ); + setLoading(false); + }, + [repoFullName], + ); useEffect(() => { - void load(); + let cancelled = false; + void load({ cancelled: () => cancelled }); + return () => { + cancelled = true; + }; }, [load]); function setField(key: K, value: MaintainerSettings[K]) { @@ -520,21 +530,31 @@ function FocusManifestEditor({ base }: { base: string | null }) { const [busy, setBusy] = useState(false); const [message, setMessage] = useState(null); - const load = useCallback(async () => { - if (!base) return; - setLoading(true); - setMessage(null); - const result = await apiFetch(`${base}/focus-manifest`, { - label: "Focus manifest", - credentials: "include", - silentStatus: true, - }); - setText(result.ok ? JSON.stringify(result.data.manifest, null, 2) : ""); - setLoading(false); - }, [base]); + const load = useCallback( + async (opts?: { cancelled?: () => boolean }) => { + const isCancelled = opts?.cancelled ?? (() => false); + if (!base) return; + setLoading(true); + setMessage(null); + const result = await apiFetch(`${base}/focus-manifest`, { + label: "Focus manifest", + credentials: "include", + silentStatus: true, + }); + // Ignore responses after a newer base keyed a fresh load (#7784). + if (isCancelled()) return; + setText(result.ok ? JSON.stringify(result.data.manifest, null, 2) : ""); + setLoading(false); + }, + [base], + ); useEffect(() => { - void load(); + let cancelled = false; + void load({ cancelled: () => cancelled }); + return () => { + cancelled = true; + }; }, [load]); async function save() {