fix(mobile-inbox): ignore stale fetches after tab switch (core#2766 / CR2 #11478) #2828

Merged
devops-engineer merged 1 commits from fix/2766-mobile-inbox-stale-action into main 2026-06-14 06:17:05 +00:00
2 changed files with 34 additions and 19 deletions
+21 -3
View File
@@ -45,13 +45,31 @@ export function MobileInbox({ dark }: { dark: boolean }) {
return () => { cancelled = true; };
}, []);
// Guard against stale fetches: when the user switches tabs, a previous
// in-flight fetch for the old kind must not overwrite the list after the
// new tab's load clears it (CR2 #11478).
const loadSeqRef = useRef(0);
const load = useCallback(() => {
const seq = ++loadSeqRef.current;
// core#2766 / CR2 #11478: clear stale rows the moment the kind changes
// so the user never sees approval cards under the Tasks tab (or
// vice-versa) while the new list is still fetching.
setItems([]);
setLoading(true);
api
.get<RequestRow[]>(`/requests/pending?kind=${kind}`)
.then((rows) => setItems(Array.isArray(rows) ? rows : []))
.catch(() => setItems([]))
.finally(() => setLoading(false));
.then((rows) => {
if (seq !== loadSeqRef.current) return; // stale response
setItems(Array.isArray(rows) ? rows : []);
})
.catch(() => {
if (seq !== loadSeqRef.current) return; // stale response
setItems([]);
})
.finally(() => {
if (seq !== loadSeqRef.current) return; // stale response
setLoading(false);
});
}, [kind]);
useEffect(() => { load(); }, [load]);
@@ -84,28 +84,25 @@ describe("MobileInbox", () => {
// Switch to Tasks before the task fetch resolves.
fireEvent.click(getByRole("tab", { name: "Tasks" }));
// Stale approval row is still visible.
expect(getByText("Delete prod secret?")).toBeTruthy();
// Primary action is still "Approve", not "Done".
expect(getByRole("button", { name: "Approve" })).toBeTruthy();
// Switch to Tasks. The new load clears stale rows immediately.
fireEvent.click(getByRole("tab", { name: "Tasks" }));
expect(queryByText("Delete prod secret?")).toBeNull();
// The old approval fetch now resolves. With the sequence guard it
// must be ignored: the Task tab should NOT show approval rows.
await act(async () => {
fireEvent.click(getByRole("button", { name: "Approve" }));
approvalFetch.resolve([approval]);
});
await waitFor(() => {
expect(queryByText("Delete prod secret?")).toBeNull();
});
// Must post the approval action, never the task action.
expect(api.post).toHaveBeenCalledWith(
"/requests/req-1/respond",
expect.objectContaining({ action: "approved" }),
);
expect(api.post).not.toHaveBeenCalledWith(
"/requests/req-1/respond",
expect.objectContaining({ action: "done" }),
);
// Once the task fetch resolves, the Tasks tab shows its own content.
await act(async () => {
taskFetch.resolve([]);
});
await waitFor(() => expect(queryByText("Delete prod secret?")).toBeNull());
await waitFor(() => {
expect(getByText(/No pending tasks/i)).toBeTruthy();
});
});
});