fix(mobile-inbox): clear rows on tab switch and derive actions from row.kind (#2766) #2774

Merged
devops-engineer merged 1 commits from fix/2766-mobile-inbox-wrong-action-race into main 2026-06-13 21:46:03 +00:00
2 changed files with 90 additions and 7 deletions
@@ -111,6 +111,11 @@ export function RequestsInbox({ kind, onCountChange }: RequestsInboxProps) {
// TODO(multi-user): when the canvas grows real per-action attribution, plumb
// the acting user through props instead of a single module-level resolve.
const responderIdRef = useRef<string>("admin");
// core#2766: monotonic generation counter so a stale `/requests/pending`
// response from a previous `kind` cannot overwrite the list after a tab
// switch (or after a later live-refresh load).
const loadGenRef = useRef(0);
useEffect(() => {
let cancelled = false;
fetchSession()
@@ -123,14 +128,31 @@ export function RequestsInbox({ kind, onCountChange }: RequestsInboxProps) {
return () => { cancelled = true; };
}, []);
// Increment the generation on unmount so any in-flight load cannot call
// setItems after this component instance is gone.
useEffect(() => {
return () => {
loadGenRef.current += 1;
};
}, []);
const load = useCallback(() => {
const gen = ++loadGenRef.current;
// core#2766: 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. This prevents the wrong-action race where a
// stale approval row could be actioned with action="done".
setItems([]);
onCountChange?.(0);
api.get<RequestRow[]>(`/requests/pending?kind=${kind}`)
.then((r) => {
if (gen !== loadGenRef.current) return; // stale response
const list = r ?? [];
setItems(list);
onCountChange?.(list.length);
})
.catch(() => {
if (gen !== loadGenRef.current) return; // stale response
setItems([]);
onCountChange?.(0);
});
@@ -197,7 +219,6 @@ export function RequestsInbox({ kind, onCountChange }: RequestsInboxProps) {
<RequestItem
key={r.id}
row={r}
kind={kind}
acting={acting === r.id}
threadOpen={openThread === r.id}
onRespond={respond}
@@ -211,7 +232,6 @@ export function RequestsInbox({ kind, onCountChange }: RequestsInboxProps) {
interface RequestItemProps {
row: RequestRow;
kind: RequestKind;
acting: boolean;
threadOpen: boolean;
onRespond: (r: RequestRow, action: "done" | "rejected" | "approved") => void;
@@ -223,10 +243,14 @@ interface RequestItemProps {
* approval layout reuses the .apprCard classes. Both share the inline
* More-Info thread panel. */
function RequestItem({
row, kind, acting, threadOpen, onRespond, onToggleThread, responderId,
row, acting, threadOpen, onRespond, onToggleThread, responderId,
}: RequestItemProps) {
const badge = statusLabel(row.status);
const isApproval = kind === "approval";
// core#2766: derive the card layout and primary action from the ROW'S kind,
// not from the selected tab. During a tab switch the old tab's rows are
// cleared, but if any stale render slips through it must not expose a
// task "Done" button on an approval row.
const isApproval = row.kind === "approval";
// "Responder identity" on a resolved row (shows only if a resolved item
// ever renders in this pending view — defensive, since pending excludes
@@ -296,7 +320,7 @@ function RequestItem({
if (isApproval) {
return (
<div className={s.apprCard} style={{ marginBottom: 7 }} data-testid="request-item" data-kind="approval">
<div className={s.apprCard} style={{ marginBottom: 7 }} data-testid="request-item" data-kind={row.kind}>
<div className={s.apprRow}>
<div className={s.apprIc}><IcTrash /></div>
<div className={s.apprMeta}>
@@ -320,7 +344,7 @@ function RequestItem({
}
return (
<div className={s.task} data-testid="request-item" data-kind="task">
<div className={s.task} data-testid="request-item" data-kind={row.kind}>
<div className={s.taskRow}>
<div className={`${s.taskIc} ${s.run}`}><IcClock /></div>
<div className={s.taskMeta}>
@@ -12,7 +12,7 @@
* vi.resetModules() in afterEach undoes the mocks for other files.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RequestsInbox, type RequestRow } from "../RequestsInbox";
import { showToast } from "@/components/Toaster";
@@ -185,6 +185,65 @@ describe("RequestsInbox — More Info thread", () => {
});
});
describe("RequestsInbox — tab-switch wrong-action race (core#2766)", () => {
it("clears stale rows immediately on kind switch before the new fetch resolves", async () => {
mockApiGet.mockResolvedValue([approvalRow("a1")]);
const { rerender } = render(<RequestsInbox kind="approval" />);
await waitFor(() =>
expect(screen.getByText("Delete production volume")).toBeTruthy(),
);
expect(screen.getByRole("button", { name: /approve/i })).toBeTruthy();
// Switch to the Tasks tab with a fetch that never resolves in this test.
mockApiGet.mockImplementation(() => new Promise(() => {}));
rerender(<RequestsInbox kind="task" />);
// The stale approval row must be gone instantly; otherwise the user could
// see approval cards under the Tasks tab and the wrong primary action.
expect(screen.queryByText("Delete production volume")).toBeNull();
expect(screen.queryByTestId("request-item")).toBeNull();
expect(screen.queryByRole("button", { name: /approve/i })).toBeNull();
});
it("ignores a stale approval fetch that resolves after switching to Tasks", async () => {
let resolveApproval: (value: RequestRow[]) => void = () => {};
mockApiGet.mockImplementation((path: string) => {
if (path === "/requests/pending?kind=approval") {
return new Promise<RequestRow[]>((res) => {
resolveApproval = res;
});
}
return Promise.resolve([]);
});
const { rerender } = render(<RequestsInbox kind="approval" />);
// The approval fetch is in flight but has not resolved yet.
expect(screen.queryByText("Delete production volume")).toBeNull();
// Switch to Tasks before the approval response lands.
rerender(<RequestsInbox kind="task" />);
// The old approval response finally arrives; it must be ignored.
await act(async () => {
resolveApproval([approvalRow("a1")]);
});
expect(screen.queryByText("Delete production volume")).toBeNull();
expect(screen.queryByTestId("request-item")).toBeNull();
expect(screen.queryByRole("button", { name: /approve/i })).toBeNull();
});
it("derives action buttons from row.kind, not the selected tab", async () => {
// Defensive: even if a mismatched row somehow reaches the list, its action
// must be driven by row.kind so an approval can never be actioned as "done".
mockApiGet.mockResolvedValue([approvalRow("a1")]);
await act(async () => { render(<RequestsInbox kind="task" />); });
expect(screen.queryByRole("button", { name: /done/i })).toBeNull();
expect(screen.getByRole("button", { name: /approve/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /reject/i })).toBeTruthy();
});
});
describe("RequestsInbox — live refresh + toasts", () => {
it("subscribes to the socket bus on mount", async () => {
await act(async () => { render(<RequestsInbox kind="task" />); });