From 2e28d4ed4c5463caece535a99e9884f9ef1e9c05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 5 Nov 2026 13:05:57 +0100 Subject: [PATCH] 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. --- .../medication-intake-quick-add.test.tsx | 6 +- .../__tests__/take-all-due.test.tsx | 1 + .../__tests__/modules-section.test.tsx | 1 + src/lib/__tests__/query-keys.test.ts | 10 ++- .../__tests__/invalidate-keys.test.ts | 84 +++++++++++++++++++ src/lib/query-keys/index.ts | 32 ++++++- 6 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 src/lib/query-keys/__tests__/invalidate-keys.test.ts diff --git a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx index 09599b48c..584b08b36 100644 --- a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx +++ b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx @@ -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); diff --git a/src/components/medications/__tests__/take-all-due.test.tsx b/src/components/medications/__tests__/take-all-due.test.tsx index 643ae8be7..9426e49b7 100644 --- a/src/components/medications/__tests__/take-all-due.test.tsx +++ b/src/components/medications/__tests__/take-all-due.test.tsx @@ -89,6 +89,7 @@ const t = (key: string, params?: Record) => function fakeQueryClient(): QueryClientType { return { invalidateQueries: vi.fn().mockResolvedValue(undefined), + getQueryCache: () => ({ findAll: () => [] }), } as unknown as QueryClientType; } diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx index ea3e94851..660b7c669 100644 --- a/src/components/settings/__tests__/modules-section.test.tsx +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -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, diff --git a/src/lib/__tests__/query-keys.test.ts b/src/lib/__tests__/query-keys.test.ts index db0e3da1d..cab8d1f08 100644 --- a/src/lib/__tests__/query-keys.test.ts +++ b/src/lib/__tests__/query-keys.test.ts @@ -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); diff --git a/src/lib/query-keys/__tests__/invalidate-keys.test.ts b/src/lib/query-keys/__tests__/invalidate-keys.test.ts new file mode 100644 index 000000000..39a5f3945 --- /dev/null +++ b/src/lib/query-keys/__tests__/invalidate-keys.test.ts @@ -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((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}`]); + }); +}); diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 9f43af097..f3423a5ac 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -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[]> { return Promise.allSettled( - keys.map((queryKey) => queryClient.invalidateQueries({ queryKey })), + keys.map((queryKey) => invalidateKey(queryClient, queryKey)), ); } +async function invalidateKey( + queryClient: QueryClient, + queryKey: QueryKey, +): Promise { + 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