fix(ui): stop refetching cache-only queries without a queryFn (#18062)

Task snapshots are cached under each alias key (UUID and identifier) with setQueryData. Those entries have no queryFn until a view observes them, so inactive invalidations refetched them and failed with "Missing queryFn". The app QueryClient now skips entries without a queryFn when it refetches; invalidateQueries still marks them stale.

Fixes PAPERCLIP-FRONTEND-X

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Devin FoleyandClaude Opus 5.5 authored and GitHub committed 2026-10-28 14:17:29 -07:00
1 parent a30194e008
commit a39f1b81f1
2 files changed
+56

No files matched your search

+39
View File
@@ -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);
});
});
+17
View File
@@ -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<TTaggedQueryKey extends QueryKey = QueryKey>(
filters?: RefetchQueryFilters<TTaggedQueryKey>,
options?: RefetchOptions,
): Promise<void> {
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. */