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. diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index aa9ee2406..1dd6e21c8 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -1606,6 +1606,417 @@ 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, + }), + ) + + 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 }) + + 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 () => { + 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, + }), + ) + + 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 }) + + 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 () => { + 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, + }), + ) + + 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`, + }) + } finally { + await collection.cleanup() + } + }) + + // 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` }]