From 03def3e49e2fd2b365a71e5d691b482ef17a784d Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 7 Jul 2026 14:55:36 -0600 Subject: [PATCH 1/4] test(query-db-collection): cover invalidation behavior matrix --- .../query-db-collection/tests/query.test.ts | 393 ++++++++++++++++++ 1 file changed, 393 insertions(+) diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index aa9ee2406..6360107c0 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -1606,6 +1606,399 @@ describe(`QueryCollection`, () => { }) }) + describe(`invalidation behavior`, () => { + it(`rematerializes an active eager query after exact invalidation`, async () => { + const queryKey = [`invalidation-exact-eager-test`] + let items: Array = [{ id: `1`, name: `Item 1` }] + const queryFn = vi.fn().mockImplementation(() => Promise.resolve(items)) + + const collection = createCollection( + queryCollectionOptions({ + id: `invalidation-exact-eager-test`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + }), + ) + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(stripVirtualProps(collection.get(`1`))).toEqual({ + id: `1`, + name: `Item 1`, + }) + }) + + items = [{ id: `1`, name: `Updated Item 1` }] + await queryClient.invalidateQueries({ queryKey, exact: true }) + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(2) + expect(stripVirtualProps(collection.get(`1`))).toEqual({ + id: `1`, + name: `Updated Item 1`, + }) + }) + }) + + it(`rematerializes an active eager query after prefix invalidation`, async () => { + const rootQueryKey = [`invalidation-prefix-eager-test`] + const queryKey = [...rootQueryKey, `child`] + let items: Array = [{ id: `1`, name: `Item 1` }] + const queryFn = vi.fn().mockImplementation(() => Promise.resolve(items)) + + const collection = createCollection( + queryCollectionOptions({ + id: `invalidation-prefix-eager-test`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + }), + ) + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(stripVirtualProps(collection.get(`1`))).toEqual({ + id: `1`, + name: `Item 1`, + }) + }) + + items = [{ id: `1`, name: `Updated Item 1` }] + await queryClient.invalidateQueries({ queryKey: rootQueryKey }) + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(2) + expect(stripVirtualProps(collection.get(`1`))).toEqual({ + id: `1`, + name: `Updated Item 1`, + }) + }) + }) + + it(`rematerializes an active on-demand subset after exact invalidation`, async () => { + const queryKey = [`invalidation-exact-on-demand-test`] + let items: Array = [{ id: `1`, name: `Item 1` }] + const queryFn = vi.fn().mockImplementation(() => Promise.resolve(items)) + + const collection = createCollection( + queryCollectionOptions({ + id: `invalidation-exact-on-demand-test`, + queryClient, + queryKey, + queryFn, + getKey, + syncMode: `on-demand`, + }), + ) + const liveQuery = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .select(({ item }) => ({ id: item.id, name: item.name })), + }) + + try { + await liveQuery.preload() + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(stripVirtualProps(collection.get(`1`))).toEqual({ + id: `1`, + name: `Item 1`, + }) + }) + + items = [{ id: `1`, name: `Updated Item 1` }] + await queryClient.invalidateQueries({ queryKey, exact: true }) + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(2) + expect(stripVirtualProps(collection.get(`1`))).toEqual({ + id: `1`, + name: `Updated Item 1`, + }) + }) + } finally { + await liveQuery.cleanup() + } + }) + + it(`rematerializes an active on-demand subset after root prefix invalidation`, async () => { + const queryKey = [`invalidation-prefix-on-demand-test`] + let items: Array = [{ id: `1`, name: `Item 1` }] + const observedQueryKeys: Array> = [] + const queryFn = vi + .fn() + .mockImplementation( + (ctx: QueryFunctionContext>) => { + observedQueryKeys.push(ctx.queryKey) + return Promise.resolve(items) + }, + ) + + const collection = createCollection( + queryCollectionOptions({ + id: `invalidation-prefix-on-demand-test`, + queryClient, + queryKey, + queryFn, + getKey, + syncMode: `on-demand`, + }), + ) + const liveQuery = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => eq(item.id, `1`)) + .select(({ item }) => ({ id: item.id, name: item.name })), + }) + + try { + await liveQuery.preload() + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(observedQueryKeys[0]?.length).toBeGreaterThan(queryKey.length) + expect(stripVirtualProps(collection.get(`1`))).toEqual({ + id: `1`, + name: `Item 1`, + }) + }) + + items = [{ id: `1`, name: `Updated Item 1` }] + await queryClient.invalidateQueries({ queryKey }) + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(2) + expect(stripVirtualProps(collection.get(`1`))).toEqual({ + id: `1`, + name: `Updated Item 1`, + }) + }) + } finally { + await liveQuery.cleanup() + } + }) + + it(`keeps overlapping on-demand subset rows materialized when one subset is invalidated`, async () => { + const queryKey = [`invalidation-overlap-on-demand-test`] + let firstSubset: Array = [ + { id: `1`, name: `Item 1` }, + { id: `2`, name: `Shared Item` }, + ] + const secondSubset: Array = [ + { id: `2`, name: `Shared Item` }, + { id: `3`, name: `Item 3` }, + ] + const observedQueryKeys: Array> = [] + const queryFn = vi + .fn() + .mockImplementation( + (ctx: QueryFunctionContext>) => { + const firstObservedKey = observedQueryKeys[0] + observedQueryKeys.push(ctx.queryKey) + return Promise.resolve( + firstObservedKey === undefined || + hashKey(ctx.queryKey) === hashKey(firstObservedKey) + ? firstSubset + : secondSubset, + ) + }, + ) + + const collection = createCollection( + queryCollectionOptions({ + id: `invalidation-overlap-on-demand-test`, + queryClient, + queryKey, + queryFn, + getKey, + syncMode: `on-demand`, + }), + ) + const firstLiveQuery = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => inArray(item.id, [`1`, `2`])) + .select(({ item }) => ({ id: item.id, name: item.name })), + }) + const secondLiveQuery = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => inArray(item.id, [`2`, `3`])) + .select(({ item }) => ({ id: item.id, name: item.name })), + }) + + try { + await firstLiveQuery.preload() + await secondLiveQuery.preload() + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(2) + expect(collection.size).toBe(3) + }) + + firstSubset = [ + { id: `1`, name: `Updated Item 1` }, + { id: `2`, name: `Updated Shared Item` }, + ] + await queryClient.invalidateQueries({ + queryKey: observedQueryKeys[0], + exact: true, + }) + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(3) + expect(stripVirtualProps(collection.get(`1`))).toEqual({ + id: `1`, + name: `Updated Item 1`, + }) + expect(stripVirtualProps(collection.get(`2`))).toEqual({ + id: `2`, + name: `Updated Shared Item`, + }) + }) + expect(stripVirtualProps(collection.get(`3`))).toEqual({ + id: `3`, + name: `Item 3`, + }) + } finally { + await firstLiveQuery.cleanup() + await secondLiveQuery.cleanup() + } + }) + + it(`retains existing rows when an invalidation refetch fails`, async () => { + const queryKey = [`invalidation-failed-refetch-test`] + const initialItems: Array = [{ id: `1`, name: `Item 1` }] + const refetchError = new Error(`refetch failed`) + const queryFn = vi + .fn() + .mockResolvedValueOnce(initialItems) + .mockRejectedValueOnce(refetchError) + + const collection = createCollection( + queryCollectionOptions({ + id: `invalidation-failed-refetch-test`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + retry: false, + }), + ) + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(stripVirtualProps(collection.get(`1`))).toEqual({ + id: `1`, + name: `Item 1`, + }) + }) + + await queryClient.invalidateQueries({ queryKey, exact: true }) + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(2) + expect(collection.utils.lastError).toBe(refetchError) + }) + expect(collection.size).toBe(1) + expect(stripVirtualProps(collection.get(`1`))).toEqual({ + id: `1`, + name: `Item 1`, + }) + }) + + // Retained/persisted invalidation is intentionally not covered here: the + // existing unit fixtures do not exercise the full persisted retention path + // without introducing broader persistence setup. This PR characterizes active, + // inactive, removed, overlapping, and failed-refetch behavior first. + it(`does not rematerialize inactive cached query rows after invalidation`, async () => { + const retainedQueryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 0, + gcTime: 60_000, + retry: false, + }, + }, + }) + const queryKey = [`invalidation-inactive-cached-test`] + let items: Array = [{ id: `1`, name: `Item 1` }] + const queryFn = vi.fn().mockImplementation(() => Promise.resolve(items)) + + const collection = createCollection( + queryCollectionOptions({ + id: `invalidation-inactive-cached-test`, + queryClient: retainedQueryClient, + queryKey, + queryFn, + getKey, + startSync: true, + }), + ) + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(collection.size).toBe(1) + }) + + await collection.cleanup() + expect(collection.status).toBe(`cleaned-up`) + expect( + retainedQueryClient.getQueryCache().find({ queryKey }), + ).toBeDefined() + + items = [{ id: `1`, name: `Updated Item 1` }] + await retainedQueryClient.invalidateQueries({ queryKey, exact: true }) + + expect(queryFn).toHaveBeenCalledTimes(1) + expect(collection.size).toBe(0) + retainedQueryClient.clear() + }) + + it(`does not refetch a removed query after invalidation`, async () => { + const queryKey = [`invalidation-removed-query-test`] + let items: Array = [{ id: `1`, name: `Item 1` }] + const queryFn = vi.fn().mockImplementation(() => Promise.resolve(items)) + + const collection = createCollection( + queryCollectionOptions({ + id: `invalidation-removed-query-test`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + }), + ) + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(collection.size).toBe(1) + }) + + await collection.cleanup() + queryClient.removeQueries({ queryKey, exact: true }) + expect(queryClient.getQueryCache().find({ queryKey })).toBeUndefined() + + items = [{ id: `1`, name: `Updated Item 1` }] + await queryClient.invalidateQueries({ queryKey, exact: true }) + + expect(queryFn).toHaveBeenCalledTimes(1) + expect(collection.size).toBe(0) + }) + }) + it(`should handle concurrent query operations`, async () => { const queryKey = [`concurrent-test`] const items = [{ id: `1`, name: `Item 1` }] From d961b7707db16d9d5df8e3720eb204824a60d443 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 7 Jul 2026 14:55:45 -0600 Subject: [PATCH 2/4] chore: add query invalidation changeset --- .changeset/query-invalidation-matrix.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/query-invalidation-matrix.md diff --git a/.changeset/query-invalidation-matrix.md b/.changeset/query-invalidation-matrix.md new file mode 100644 index 000000000..9737b7e0e --- /dev/null +++ b/.changeset/query-invalidation-matrix.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-db-collection': patch +--- + +Add coverage for query invalidation behavior across eager and on-demand query collections. From a09ecca1b1f171de2a08a2fa58b7a409e93e2f35 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:57:43 +0000 Subject: [PATCH 3/4] ci: apply automated fixes --- packages/query-db-collection/tests/query.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 6360107c0..591e3f9e4 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -1764,7 +1764,9 @@ describe(`QueryCollection`, () => { await vi.waitFor(() => { expect(queryFn).toHaveBeenCalledTimes(1) - expect(observedQueryKeys[0]?.length).toBeGreaterThan(queryKey.length) + expect(observedQueryKeys[0]?.length).toBeGreaterThan( + queryKey.length, + ) expect(stripVirtualProps(collection.get(`1`))).toEqual({ id: `1`, name: `Item 1`, From 23ae479afb7a95d0c8b389a75430d76ed13dce70 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 7 Jul 2026 15:08:36 -0600 Subject: [PATCH 4/4] test(query-db-collection): clean up invalidation observers --- .../query-db-collection/tests/query.test.ts | 102 ++++++++++-------- 1 file changed, 59 insertions(+), 43 deletions(-) diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 591e3f9e4..1dd6e21c8 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -1623,24 +1623,32 @@ describe(`QueryCollection`, () => { }), ) - await vi.waitFor(() => { - expect(queryFn).toHaveBeenCalledTimes(1) - expect(stripVirtualProps(collection.get(`1`))).toEqual({ - id: `1`, - name: `Item 1`, + try { + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(stripVirtualProps(collection.get(`1`))).toEqual({ + id: `1`, + name: `Item 1`, + }) }) - }) - items = [{ id: `1`, name: `Updated Item 1` }] - await queryClient.invalidateQueries({ queryKey, exact: true }) + items = [{ id: `1`, name: `Updated Item 1` }] + await queryClient.invalidateQueries({ queryKey, exact: true }) - await vi.waitFor(() => { - expect(queryFn).toHaveBeenCalledTimes(2) - expect(stripVirtualProps(collection.get(`1`))).toEqual({ - id: `1`, - name: `Updated Item 1`, + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(2) + expect(stripVirtualProps(collection.get(`1`))).toEqual({ + id: `1`, + name: `Updated Item 1`, + }) }) - }) + } finally { + await collection.cleanup() + } + + expect( + queryClient.getQueryCache().find({ queryKey })?.getObserversCount(), + ).toBe(0) }) it(`rematerializes an active eager query after prefix invalidation`, async () => { @@ -1660,24 +1668,28 @@ describe(`QueryCollection`, () => { }), ) - await vi.waitFor(() => { - expect(queryFn).toHaveBeenCalledTimes(1) - expect(stripVirtualProps(collection.get(`1`))).toEqual({ - id: `1`, - name: `Item 1`, + try { + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(stripVirtualProps(collection.get(`1`))).toEqual({ + id: `1`, + name: `Item 1`, + }) }) - }) - items = [{ id: `1`, name: `Updated Item 1` }] - await queryClient.invalidateQueries({ queryKey: rootQueryKey }) + items = [{ id: `1`, name: `Updated Item 1` }] + await queryClient.invalidateQueries({ queryKey: rootQueryKey }) - await vi.waitFor(() => { - expect(queryFn).toHaveBeenCalledTimes(2) - expect(stripVirtualProps(collection.get(`1`))).toEqual({ - id: `1`, - name: `Updated Item 1`, + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(2) + expect(stripVirtualProps(collection.get(`1`))).toEqual({ + id: `1`, + name: `Updated Item 1`, + }) }) - }) + } finally { + await collection.cleanup() + } }) it(`rematerializes an active on-demand subset after exact invalidation`, async () => { @@ -1899,25 +1911,29 @@ describe(`QueryCollection`, () => { }), ) - await vi.waitFor(() => { - expect(queryFn).toHaveBeenCalledTimes(1) + try { + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(stripVirtualProps(collection.get(`1`))).toEqual({ + id: `1`, + name: `Item 1`, + }) + }) + + await queryClient.invalidateQueries({ queryKey, exact: true }) + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(2) + expect(collection.utils.lastError).toBe(refetchError) + }) + expect(collection.size).toBe(1) expect(stripVirtualProps(collection.get(`1`))).toEqual({ id: `1`, name: `Item 1`, }) - }) - - await queryClient.invalidateQueries({ queryKey, exact: true }) - - await vi.waitFor(() => { - expect(queryFn).toHaveBeenCalledTimes(2) - expect(collection.utils.lastError).toBe(refetchError) - }) - expect(collection.size).toBe(1) - expect(stripVirtualProps(collection.get(`1`))).toEqual({ - id: `1`, - name: `Item 1`, - }) + } finally { + await collection.cleanup() + } }) // Retained/persisted invalidation is intentionally not covered here: the