fix(query): refetch a query whose first load was in flight at invalidation

TanStack restarts an in-flight fetch on invalidation only when the query
already holds data. A query still on its first load gets the in-flight
promise back, and that fetch's success clears the invalidated flag, so an
answer read before the write sticks with nothing scheduled to correct it.

The vault hit this when an upload settled just as a typed search switched
the list to a fresh key: the list stayed on "No matching documents" while
the stored row existed. invalidateKeys now waits for such a first load to
end and invalidates that query once more.
This commit is contained in:
Marc-André Bombeck committed 2026-11-05 13:05:57 +01:00
1 parent 63f9d2f60e
commit 2e28d4ed4c
6 files changed
+130 -4

No files matched your search

@@ -39,7 +39,10 @@ import {
// populated medications list; per-test mockReturnValue calls override.
const mockUseQuery = vi.fn();
const mockInvalidateQueries = vi.fn().mockResolvedValue(undefined);
const mockQueryClient = { invalidateQueries: mockInvalidateQueries };
const mockQueryClient = {
invalidateQueries: mockInvalidateQueries,
getQueryCache: () => ({ findAll: () => [] }),
};
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: unknown) => mockUseQuery(opts),
useQueryClient: () => mockQueryClient,
@@ -411,6 +414,7 @@ describe("submit invalidation — the quick-add is a capture-picker surface, not
const invalidateQueries = vi.fn().mockResolvedValue(undefined);
const queryClient = {
invalidateQueries,
getQueryCache: () => ({ findAll: () => [] }),
} as unknown as QueryClientType;
await invalidateMedicationReads(queryClient);
@@ -89,6 +89,7 @@ const t = (key: string, params?: Record<string, string | number>) =>
function fakeQueryClient(): QueryClientType {
return {
invalidateQueries: vi.fn().mockResolvedValue(undefined),
getQueryCache: () => ({ findAll: () => [] }),
} as unknown as QueryClientType;
}
@@ -40,6 +40,7 @@ const invalidateSpy = vi.fn();
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({
invalidateQueries: invalidateSpy,
getQueryCache: () => ({ findAll: () => [] }),
// v1.32.22 — the writer reads/seeds the optimistic-concurrency token
// via the query cache; no token seeded → tokenless (unconditional) write.
getQueryData: () => undefined,
+8 -2
View File
@@ -449,7 +449,10 @@ describe("queryKey factory enforcement", () => {
describe("invalidateKeys", () => {
it("calls invalidateQueries for every key in the bundle", async () => {
const invalidate = vi.fn().mockResolvedValue(undefined);
const client = { invalidateQueries: invalidate } as unknown as QueryClient;
const client = {
invalidateQueries: invalidate,
getQueryCache: () => ({ findAll: () => [] }),
} as unknown as QueryClient;
const keys: QueryKey[] = [["a"], ["b", "c"], ["d"]];
await invalidateKeys(client, keys);
@@ -466,7 +469,10 @@ describe("invalidateKeys", () => {
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValueOnce(undefined);
const client = { invalidateQueries: invalidate } as unknown as QueryClient;
const client = {
invalidateQueries: invalidate,
getQueryCache: () => ({ findAll: () => [] }),
} as unknown as QueryClient;
const keys: QueryKey[] = [["a"], ["b"], ["c"]];
const results = await invalidateKeys(client, keys);
@@ -0,0 +1,84 @@
/**
* `invalidateKeys` against a query that is still on its first fetch.
*
* TanStack only restarts an in-flight fetch on invalidation when the query
* already holds data (`cancelRefetch` is ignored otherwise); a query with no
* data yet gets the in-flight promise back, and its success then clears the
* invalidated flag. So a write that lands while a freshly keyed list is
* loading — the vault's upload settling just as a typed search switches the
* list key — left the list on an answer read before the write, with nothing
* scheduled to correct it.
*/
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { afterEach, describe, expect, it } from "vitest";
import { invalidateKeys } from "@/lib/query-keys";
const KEY = ["documents", "inbound", "list", { q: "probe" }] as const;
describe("invalidateKeys", () => {
let client: QueryClient;
let unsubscribe: () => void = () => {};
afterEach(() => {
unsubscribe();
client.clear();
});
it("refetches a query whose first fetch was in flight when the write landed", async () => {
client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 60_000 } },
});
// Server state: empty until the write commits.
let stored: string[] = [];
let release: () => void = () => {};
let calls = 0;
const queryFn = () => {
calls += 1;
// The first read is taken before the write commits and answers after.
const snapshot = [...stored];
if (calls === 1) {
return new Promise<string[]>((resolve) => {
release = () => resolve(snapshot);
});
}
return Promise.resolve(snapshot);
};
const observer = new QueryObserver(client, { queryKey: KEY, queryFn });
unsubscribe = observer.subscribe(() => {});
expect(client.getQueryState(KEY)?.fetchStatus).toBe("fetching");
// The write commits, then the client invalidates — while the first
// read is still in flight.
stored = ["probe.pdf"];
const settled = invalidateKeys(client, [["documents"]]);
release();
await settled;
expect(client.getQueryData(KEY)).toEqual(["probe.pdf"]);
expect(calls).toBe(2);
});
it("does not refetch a query that already holds data more than once", async () => {
client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 60_000 } },
});
let calls = 0;
const observer = new QueryObserver(client, {
queryKey: KEY,
queryFn: () => {
calls += 1;
return Promise.resolve([`v${calls}`]);
},
});
unsubscribe = observer.subscribe(() => {});
await observer.refetch();
const before = calls;
await invalidateKeys(client, [["documents"]]);
expect(calls).toBe(before + 1);
expect(client.getQueryData(KEY)).toEqual([`v${before + 1}`]);
});
});
+31 -1
View File
@@ -391,16 +391,46 @@ export const aiInputDependentKeys = [
* invalidations (cache would otherwise be left half-stale) and so the `void
* invalidateKeys(...)` fire-and-forget pattern in delete handlers never
* surfaces an unhandled rejection.
*
* A query still on its first fetch is invalidated once more after that fetch
* settles. TanStack restarts an in-flight fetch on invalidation only when the
* query already holds data; without data it hands back the in-flight promise,
* and that fetch's success clears the invalidated flag. Its answer can predate
* the write being announced: an upload settling just as a typed search
* switches the vault list to a fresh key left the list on a read taken before
* the row existed, with nothing scheduled to correct it.
*/
export function invalidateKeys(
queryClient: QueryClient,
keys: readonly QueryKey[],
): Promise<PromiseSettledResult<unknown>[]> {
return Promise.allSettled(
keys.map((queryKey) => queryClient.invalidateQueries({ queryKey })),
keys.map((queryKey) => invalidateKey(queryClient, queryKey)),
);
}
async function invalidateKey(
queryClient: QueryClient,
queryKey: QueryKey,
): Promise<void> {
const firstLoads = queryClient.getQueryCache().findAll({
queryKey,
predicate: (query) =>
query.state.data === undefined && query.state.fetchStatus === "fetching",
});
await Promise.all([
queryClient.invalidateQueries({ queryKey }),
...firstLoads.map(async (query) => {
// Its outcome is not ours to report; only that it has ended.
await query.promise?.catch(() => undefined);
await queryClient.invalidateQueries({
queryKey: query.queryKey,
exact: true,
});
}),
]);
}
/**
* Force the dashboard snapshot AND the Today digest to refetch even while
* unmounted. `invalidateKeys` (and every `*DependentKeys` bundle above) marks