From ae5401920a3d9154e8f5c0716a4a1808d9541fa7 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Sun, 14 Jun 2026 00:22:52 +0000 Subject: [PATCH] fix(mobile-inbox): ignore stale fetches after tab switch (core#2766 / CR2 #11478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tag each /requests/pending fetch with an incrementing sequence token. - Clear rows immediately when the active kind changes so the user never sees approval cards under the Tasks tab while the new list is loading. - Ignore responses from superseded loads before calling setItems or setLoading(false). - Add regression test: approval fetch in-flight → switch to Tasks → old approval fetch resolves → Task tab stays empty. Fixes #2766 / CR2 #11478 Co-Authored-By: Claude --- canvas/src/components/mobile/MobileInbox.tsx | 24 +++++++++++++-- .../mobile/__tests__/MobileInbox.test.tsx | 29 +++++++++---------- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/canvas/src/components/mobile/MobileInbox.tsx b/canvas/src/components/mobile/MobileInbox.tsx index 4429f2e8b..7676aea1d 100644 --- a/canvas/src/components/mobile/MobileInbox.tsx +++ b/canvas/src/components/mobile/MobileInbox.tsx @@ -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(`/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]); diff --git a/canvas/src/components/mobile/__tests__/MobileInbox.test.tsx b/canvas/src/components/mobile/__tests__/MobileInbox.test.tsx index 5e7d7efa9..8eb5152d2 100644 --- a/canvas/src/components/mobile/__tests__/MobileInbox.test.tsx +++ b/canvas/src/components/mobile/__tests__/MobileInbox.test.tsx @@ -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(); + }); }); }); -- 2.52.0