diff --git a/ui/src/lib/query-client.test.ts b/ui/src/lib/query-client.test.ts index ab18ae063fb..dec156ec762 100644 --- a/ui/src/lib/query-client.test.ts +++ b/ui/src/lib/query-client.test.ts @@ -275,4 +275,43 @@ describe("createAppQueryClient", () => { expect(reportError).toHaveBeenCalledTimes(1); unsubscribe(); }); + + it("leaves a cache-only alias stale instead of refetching it without a queryFn", async () => { + client.setQueryData(["issues", "detail", "issue-uuid"], { id: "issue-uuid", title: "Seeded" }); + const queryFn = vi.fn(async () => "fetched"); + client.setQueryData(["issues", "detail", "PAP-1"], "seeded"); + await client.prefetchQuery({ queryKey: ["issues", "detail", "PAP-1"], queryFn, staleTime: 0 }); + queryFn.mockClear(); + + void client.invalidateQueries({ queryKey: ["issues", "detail"], refetchType: "inactive" }); + void client.refetchQueries({ queryKey: ["issues", "detail"], type: "all" }); + await vi.advanceTimersByTimeAsync(60_000); + + const alias = client.getQueryCache().find({ queryKey: ["issues", "detail", "issue-uuid"], exact: true }); + expect(alias?.state.status).toBe("success"); + expect(alias?.state.isInvalidated).toBe(true); + expect(alias?.state.data).toEqual({ id: "issue-uuid", title: "Seeded" }); + expect(queryFn).toHaveBeenCalledTimes(2); + expect(reportError).not.toHaveBeenCalled(); + + const aliasQueryFn = vi.fn(async () => ({ id: "issue-uuid", title: "Fresh" })); + const observer = new QueryObserver(client, { queryKey: ["issues", "detail", "issue-uuid"], queryFn: aliasQueryFn }); + const unsubscribe = observer.subscribe(() => {}); + await vi.advanceTimersByTimeAsync(0); + expect(aliasQueryFn).toHaveBeenCalledTimes(1); + expect(observer.getCurrentResult().data).toEqual({ id: "issue-uuid", title: "Fresh" }); + unsubscribe(); + }); + + it("keeps the caller's refetch predicate", async () => { + const keep = vi.fn(async () => "keep"); + const skip = vi.fn(async () => "skip"); + await client.prefetchQuery({ queryKey: ["keep"], queryFn: keep }); + await client.prefetchQuery({ queryKey: ["skip"], queryFn: skip }); + + await client.refetchQueries({ predicate: (query) => query.queryKey[0] === "keep" }); + + expect(keep).toHaveBeenCalledTimes(2); + expect(skip).toHaveBeenCalledTimes(1); + }); }); diff --git a/ui/src/lib/query-client.ts b/ui/src/lib/query-client.ts index f3a541b016f..bba35c0257b 100644 --- a/ui/src/lib/query-client.ts +++ b/ui/src/lib/query-client.ts @@ -24,6 +24,9 @@ import { type Mutation, type MutationOptions, type QueryClientConfig, + type QueryKey, + type RefetchOptions, + type RefetchQueryFilters, } from "@tanstack/react-query"; import { classifyError, describeError, errorRetryAfterMs, isTransientError } from "@/api/errors"; import { MAX_PROBE_DELAY_MS, PROBE_BACKOFF_MS, type ConnectivityStore } from "./connectivity"; @@ -144,6 +147,20 @@ class AppQueryClient extends QueryClient { retryDelay: options?.retryDelay ?? retryDelayFor, }; } + + override refetchQueries( + filters?: RefetchQueryFilters, + options?: RefetchOptions, + ): Promise { + const predicate = filters?.predicate; + return super.refetchQueries( + { + ...filters, + predicate: (query) => query.options.queryFn !== undefined && (predicate?.(query) ?? true), + }, + options, + ); + } } /** A polling query that keeps failing reports once per window, not on every poll. */