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