From a9d0946e64ed92499db742450bf2924f4abefa3b Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Mon, 9 Nov 2026 18:20:57 -0800 Subject: [PATCH] fix(ui): keep the persisted cache's final state when it cancels reads (#20431) The persisted read cache cancels in-flight reads with revert: false when it clears account data or evicts a denied task, then writes their final state itself. A non-silent cancel settled later as a query error, which overwrote that state with CancelledError and reached QueryCache.onError. Cancel these reads silently so the written state stays. Refs #20430 Co-Authored-By: Claude Opus 5.5 --- ui/src/lib/persisted-query-cache.ts | 6 ++- ui/src/lib/query-cancellation.test.ts | 58 +++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 ui/src/lib/query-cancellation.test.ts diff --git a/ui/src/lib/persisted-query-cache.ts b/ui/src/lib/persisted-query-cache.ts index 6eaed121d95..fee8fdf20f9 100644 --- a/ui/src/lib/persisted-query-cache.ts +++ b/ui/src/lib/persisted-query-cache.ts @@ -117,7 +117,9 @@ export function persistedQueryScope(key: readonly unknown[], data: unknown, user function clearAccountData(client: QueryClient) { detachInflightGets(); const keep = (query: Query) => hashKey(query.queryKey) === hashKey(queryKeys.health) || hashKey(query.queryKey) === hashKey(queryKeys.auth.session); - void client.cancelQueries({ predicate: query => !keep(query) }, { revert: false }); + // Silent: the state written below is final. A non-silent cancel settles + // later as a query error and would replace it with `CancelledError`. + void client.cancelQueries({ predicate: query => !keep(query) }, { revert: false, silent: true }); for (const query of client.getQueryCache().getAll()) { if (keep(query)) continue; query.setState({ data: undefined, dataUpdatedAt: 0, error: null, status: "pending", fetchStatus: "idle", dataUpdateCount: 0 }); @@ -444,7 +446,7 @@ export function startPersistedQueryCache(client: QueryClient, options: { for (const id of issueRefs(query)) ids.add(id); } for (const id of ids) deniedIssues.add(id); - void client.cancelQueries({ predicate: matches }, { revert: false }); + void client.cancelQueries({ predicate: matches }, { revert: false, silent: true }); for (const query of client.getQueryCache().getAll().filter(matches)) query.setState({ data: undefined, dataUpdatedAt: 0, error: event.action.error, status: "error", fetchStatus: "idle" }); } diff --git a/ui/src/lib/query-cancellation.test.ts b/ui/src/lib/query-cancellation.test.ts new file mode 100644 index 00000000000..e3ff8fe22fd --- /dev/null +++ b/ui/src/lib/query-cancellation.test.ts @@ -0,0 +1,58 @@ +import { CancelledError } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ApiError } from "@/api/client"; +import { startPersistedQueryCache } from "./persisted-query-cache"; +import { createAppQueryClient } from "./query-client"; +import { queryKeys } from "./queryKeys"; + +vi.mock("@/api/auth", () => ({ authApi: { getSession: vi.fn() } })); + +class MemoryStorage { + values = new Map(); + get length() { return this.values.size; } + key(index: number) { return [...this.values.keys()][index] ?? null; } + getItem(key: string) { return this.values.get(key) ?? null; } + setItem(key: string, value: string) { this.values.set(key, value); } + removeItem(key: string) { this.values.delete(key); } +} + +const reportError = vi.fn<(error: unknown) => void>(); +const handles: Array<() => void> = []; +const pending = () => new Promise(() => {}); + +function setup() { + const client = createAppQueryClient({ reportError, + connectivity: { reportError: () => {}, getSnapshot: () => ({ status: "online" }) as never } }, + { defaultOptions: { queries: { retry: false } } }); + const handle = startPersistedQueryCache(client, { storage: new MemoryStorage(), buildId: "build-one", eventTarget: null, debounceMs: 100_000 }); + handles.push(() => { handle.dispose(); client.clear(); }); + client.setQueryData(queryKeys.auth.session, { session: { id: "session-alice", userId: "alice" }, user: { id: "alice" } }); + client.setQueryData(queryKeys.companies.list("alice"), { companies: [{ id: "company-one", name: "Example" }], unauthorized: false }); + client.setQueryData([...queryKeys.access.currentBoardAccess, "alice"], { userId: "alice", companyIds: ["company-one"], isInstanceAdmin: false }); + return client; +} +afterEach(() => { for (const dispose of handles.splice(0)) dispose(); vi.clearAllMocks(); }); + +describe("persisted cache cancellation", () => { + it("is not reported when a session denial clears an in-flight read", async () => { + const client = setup(); + const inFlight = client.fetchQuery({ queryKey: queryKeys.agents.list("company-one"), queryFn: pending }); + await client.fetchQuery({ queryKey: queryKeys.auth.session, staleTime: 0, + queryFn: async () => { throw new ApiError("Unauthorized", 401, null); } }).catch(() => {}); + await expect(inFlight).rejects.toBeInstanceOf(CancelledError); + expect(client.getQueryCache().find({ queryKey: queryKeys.agents.list("company-one") })).toBeUndefined(); + expect(reportError).not.toHaveBeenCalled(); + }); + + it("does not replace a task denial on an alias whose read was still in flight", async () => { + const client = setup(); + const alias = queryKeys.issues.detail("task-one"); + client.setQueryData(alias, { id: "task-one", identifier: "TST-1", companyId: "company-one", title: "Denied task body" }); + const inFlight = client.fetchQuery({ queryKey: alias, staleTime: 0, queryFn: pending }); + const denial = new ApiError("Forbidden", 403, null); + await client.fetchQuery({ queryKey: [...alias, "core"], staleTime: 0, queryFn: async () => { throw denial; } }).catch(() => {}); + await inFlight.catch(() => {}); + expect(client.getQueryState(alias)).toMatchObject({ status: "error", fetchStatus: "idle", error: denial, data: undefined }); + expect(reportError).not.toHaveBeenCalled(); + }); +});