Skip to content

Commit 2b6399b

Browse files
fix(ui): ignore stale repo-picker fetch responses (#7784)
Bump a request generation on each load so out-of-order resolutions from free-text keystrokes cannot overwrite the repo the user finished typing. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 80eb9f3 commit 2b6399b

5 files changed

Lines changed: 64 additions & 4 deletions

File tree

apps/loopover-ui/src/components/site/app-panels/activation-preview.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { CheckCircle2 } from "lucide-react";
2-
import { useCallback, useEffect, useMemo, useState } from "react";
2+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
33

44
import { StatusPill, type Status } from "@/components/site/control-primitives";
55
import { TableScroll } from "@/components/site/data-table";
@@ -61,13 +61,17 @@ export function ActivationPreview({ reviewability }: { reviewability: Array<{ pr
6161
const [preview, setPreview] = useState<ActivationPreviewResponse | null>(null);
6262
const [loading, setLoading] = useState(false);
6363
const [loadError, setLoadError] = useState<string | null>(null);
64+
// Stale-response generation (#7784): ignore out-of-order resolutions when the free-text repo picker races.
65+
const requestGenerationRef = useRef(0);
6466

6567
const base = repoApiBase(repoFullName);
6668
const hasRepos = repoOptions.length > 0;
6769

6870
const load = useCallback(async () => {
71+
const generation = ++requestGenerationRef.current;
6972
const apiBase = repoApiBase(repoFullName);
7073
if (!apiBase) {
74+
if (generation !== requestGenerationRef.current) return;
7175
setPreview(null);
7276
setLoadError(null);
7377
return;
@@ -79,6 +83,7 @@ export function ActivationPreview({ reviewability }: { reviewability: Array<{ pr
7983
credentials: "include",
8084
silentStatus: true,
8185
});
86+
if (generation !== requestGenerationRef.current) return;
8287
if (result.ok) {
8388
setPreview(result.data);
8489
} else {

apps/loopover-ui/src/components/site/app-panels/ai-review-settings.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { KeyRound, Loader2, Save, Trash2 } from "lucide-react";
2-
import { useCallback, useEffect, useMemo, useState } from "react";
2+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
33
import { StatusPill } from "@/components/site/control-primitives";
44
import { apiFetch } from "@/lib/api/request";
55
import { getApiOrigin } from "@/lib/api/origin";
@@ -57,11 +57,14 @@ export function AiReviewSettings({ reviewability }: { reviewability: Array<{ pr:
5757
const [busy, setBusy] = useState(false);
5858
const [loading, setLoading] = useState(false);
5959
const [message, setMessage] = useState<Message | null>(null);
60+
// Stale-response generation (#7784): ignore out-of-order resolutions when the free-text repo picker races.
61+
const requestGenerationRef = useRef(0);
6062

6163
const base = repoApiBase(repoFullName);
6264
const hasRepos = repoOptions.length > 0;
6365

6466
const load = useCallback(async () => {
67+
const generation = ++requestGenerationRef.current;
6568
const apiBase = repoApiBase(repoFullName);
6669
if (!apiBase) return;
6770
setMessage(null);
@@ -78,6 +81,7 @@ export function AiReviewSettings({ reviewability }: { reviewability: Array<{ pr:
7881
silentStatus: true,
7982
}),
8083
]);
84+
if (generation !== requestGenerationRef.current) return;
8185
if (settings.ok) {
8286
setMode(settings.data.aiReviewMode ?? "off");
8387
setByok(settings.data.aiReviewByok ?? false);

apps/loopover-ui/src/components/site/app-panels/ams-miner-cohort-card.test.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,4 +139,41 @@ describe("AmsMinerCohortCard", () => {
139139
});
140140
expect(screen.getByText(/This view is unavailable for this repository\./i)).toBeTruthy();
141141
});
142+
143+
it("ignores a stale response after the free-text repo picker races (#7784)", async () => {
144+
let resolveFirst!: (value: { ok: true; data: typeof POPULATED_COMPARISON }) => void;
145+
const first = new Promise<{ ok: true; data: typeof POPULATED_COMPARISON }>((resolve) => {
146+
resolveFirst = resolve;
147+
});
148+
const secondComparison = {
149+
...POPULATED_COMPARISON,
150+
windowDays: 30,
151+
checkedSubmitterCount: 2,
152+
amsCohort: { ...POPULATED_COMPARISON.amsCohort, submitterCount: 9 },
153+
};
154+
apiFetch.mockImplementation((url: string) => {
155+
if (String(url).includes("/acme/widgets/")) return first;
156+
return Promise.resolve({ ok: true, data: secondComparison });
157+
});
158+
159+
render(
160+
<AmsMinerCohortCard
161+
reviewability={[{ pr: "acme/widgets#1" }, { pr: "other/repo#2" }]}
162+
/>,
163+
);
164+
// Initial load for acme/widgets is in flight (deferred). Type a different repo so a newer request starts.
165+
fireEvent.change(screen.getByPlaceholderText("owner/repo"), {
166+
target: { value: "other/repo" },
167+
});
168+
await waitFor(() => expect(screen.getByText(/Window: 30 days · checked 2 of/i)).toBeTruthy());
169+
170+
// Stale first response arrives late — must not overwrite the newer repo's state.
171+
resolveFirst({ ok: true, data: POPULATED_COMPARISON });
172+
await waitFor(() => expect(apiFetch).toHaveBeenCalledWith(
173+
expect.stringContaining("/v1/repos/other/repo/ams-miner-cohort"),
174+
expect.anything(),
175+
));
176+
expect(screen.getByText(/Window: 30 days · checked 2 of/i)).toBeTruthy();
177+
expect(screen.queryByText(/Window: 90 days · checked 5 of/i)).toBeNull();
178+
});
142179
});

apps/loopover-ui/src/components/site/app-panels/ams-miner-cohort-card.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useState } from "react";
1+
import { useCallback, useEffect, useRef, useState } from "react";
22

33
import { StateBoundary } from "@/components/site/state-views";
44
import { apiFetch } from "@/lib/api/request";
@@ -94,13 +94,18 @@ export function AmsMinerCohortCard({ reviewability }: { reviewability: Array<{ p
9494
const [comparison, setComparison] = useState<AmsMinerCohortComparison | null>(null);
9595
const [loading, setLoading] = useState(false);
9696
const [loadError, setLoadError] = useState<string | null>(null);
97+
// Stale-response generation (#7784): every keystroke that changes repoFullName bumps this; a response
98+
// whose generation no longer matches is ignored so an earlier in-flight fetch can't overwrite later state.
99+
const requestGenerationRef = useRef(0);
97100

98101
const base = repoApiBase(repoFullName);
99102
const hasRepos = repoOptions.length > 0;
100103

101104
const load = useCallback(async () => {
105+
const generation = ++requestGenerationRef.current;
102106
const apiBase = repoApiBase(repoFullName);
103107
if (!apiBase) {
108+
if (generation !== requestGenerationRef.current) return;
104109
setComparison(null);
105110
setLoadError(null);
106111
return;
@@ -112,6 +117,7 @@ export function AmsMinerCohortCard({ reviewability }: { reviewability: Array<{ p
112117
credentials: "include",
113118
silentStatus: true,
114119
});
120+
if (generation !== requestGenerationRef.current) return;
115121
if (result.ok) {
116122
setComparison(result.data);
117123
} else {

apps/loopover-ui/src/components/site/app-panels/maintainer-settings.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { FileCog, Loader2, Save } from "lucide-react";
2-
import { useCallback, useEffect, useMemo, useState } from "react";
2+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
33

44
import { apiFetch } from "@/lib/api/request";
55
import { getApiOrigin } from "@/lib/api/origin";
@@ -141,11 +141,14 @@ export function MaintainerSettings({ reviewability }: { reviewability: Array<{ p
141141
const [loading, setLoading] = useState(false);
142142
const [busy, setBusy] = useState(false);
143143
const [message, setMessage] = useState<Message | null>(null);
144+
// Stale-response generation (#7784): ignore out-of-order resolutions when the free-text repo picker races.
145+
const requestGenerationRef = useRef(0);
144146

145147
const base = repoApiBase(repoFullName);
146148
const hasRepos = repoOptions.length > 0;
147149

148150
const load = useCallback(async () => {
151+
const generation = ++requestGenerationRef.current;
149152
const apiBase = repoApiBase(repoFullName);
150153
if (!apiBase) return;
151154
setMessage(null);
@@ -155,6 +158,7 @@ export function MaintainerSettings({ reviewability }: { reviewability: Array<{ p
155158
credentials: "include",
156159
silentStatus: true,
157160
});
161+
if (generation !== requestGenerationRef.current) return;
158162
// Default the agent-layer fields defensively so the editor renders even against an older response shape.
159163
setSettings(
160164
result.ok
@@ -519,8 +523,11 @@ function FocusManifestEditor({ base }: { base: string | null }) {
519523
const [loading, setLoading] = useState(false);
520524
const [busy, setBusy] = useState(false);
521525
const [message, setMessage] = useState<Message | null>(null);
526+
// Stale-response generation (#7784): ignore out-of-order resolutions when `base` changes mid-flight.
527+
const requestGenerationRef = useRef(0);
522528

523529
const load = useCallback(async () => {
530+
const generation = ++requestGenerationRef.current;
524531
if (!base) return;
525532
setLoading(true);
526533
setMessage(null);
@@ -529,6 +536,7 @@ function FocusManifestEditor({ base }: { base: string | null }) {
529536
credentials: "include",
530537
silentStatus: true,
531538
});
539+
if (generation !== requestGenerationRef.current) return;
532540
setText(result.ok ? JSON.stringify(result.data.manifest, null, 2) : "");
533541
setLoading(false);
534542
}, [base]);

0 commit comments

Comments
 (0)