diff --git a/.changeset/cold-islands-move.md b/.changeset/cold-islands-move.md new file mode 100644 index 00000000000..bd70eb2c665 --- /dev/null +++ b/.changeset/cold-islands-move.md @@ -0,0 +1,5 @@ +--- +'@tanstack/vue-query': minor +--- + +add new imperitive methods to QueryClient proxy diff --git a/.changeset/true-cameras-wash.md b/.changeset/true-cameras-wash.md new file mode 100644 index 00000000000..84fbe3fa981 --- /dev/null +++ b/.changeset/true-cameras-wash.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': minor +--- + +add query and infiniteQuery methods, deprecate old imperative methods diff --git a/docs/framework/vue/guides/prefetching.md b/docs/framework/vue/guides/prefetching.md index 39464348785..12617657c35 100644 --- a/docs/framework/vue/guides/prefetching.md +++ b/docs/framework/vue/guides/prefetching.md @@ -3,24 +3,30 @@ id: prefetching title: Prefetching --- -If you're lucky enough, you may know enough about what your users will do to be able to prefetch the data they need before it's needed! If this is the case, you can use the `prefetchQuery` method to prefetch the results of a query to be placed into the cache: +If you're lucky enough, you may know enough about what your users will do to be able to prefetch the data they need before it's needed. If this is the case, use `queryClient.query` or `queryClient.infiniteQuery` to warm the cache ahead of time: [//]: # 'ExamplePrefetching' ```tsx +import { noop } from '@tanstack/vue-query' + const prefetchTodos = async () => { // The results of this query will be cached like a normal query - await queryClient.prefetchQuery({ - queryKey: ['todos'], - queryFn: fetchTodos, - }) + await queryClient + .query({ + queryKey: ['todos'], + queryFn: fetchTodos, + }) + .catch(noop) } ``` [//]: # 'ExamplePrefetching' - If **fresh** data for this query is already in the cache, the data will not be fetched -- If a `staleTime` is passed eg. `prefetchQuery({ queryKey: ['todos'], queryFn: fn, staleTime: 5000 })` and the data is older than the specified `staleTime`, the query will be fetched +- If a `staleTime` is passed e.g. `queryClient.query({ queryKey: ['todos'], queryFn: fn, staleTime: 5000 })` and the data is older than the specified `staleTime`, the query will be fetched +- As `useQuery` will retry fetches and handle errors, you can use `void` to ignore the promise from `query` and `.catch(noop)` to ignore errors. +- If you want to always return cached data when it exists, use `staleTime: 'static'` - If no instances of `useQuery` appear for a prefetched query, it will be deleted and garbage collected after the time specified in `gcTime`. ## Prefetching Infinite Queries @@ -30,15 +36,19 @@ Infinite Queries can be prefetched like regular Queries. Per default, only the f [//]: # 'ExampleInfiniteQuery' ```tsx +import { noop } from '@tanstack/vue-query' + const prefetchProjects = async () => { // The results of this query will be cached like a normal query - await queryClient.prefetchInfiniteQuery({ - queryKey: ['projects'], - queryFn: fetchProjects, - initialPageParam: 0, - getNextPageParam: (lastPage, pages) => lastPage.nextCursor, - pages: 3, // prefetch the first 3 pages - }) + await queryClient + .infiniteQuery({ + queryKey: ['projects'], + queryFn: fetchProjects, + initialPageParam: 0, + getNextPageParam: (lastPage, pages) => lastPage.nextCursor, + pages: 3, // prefetch the first 3 pages + }) + .catch(noop) } ``` diff --git a/docs/framework/vue/guides/ssr.md b/docs/framework/vue/guides/ssr.md index 623743cf736..efc43567f6d 100644 --- a/docs/framework/vue/guides/ssr.md +++ b/docs/framework/vue/guides/ssr.md @@ -50,12 +50,13 @@ export default defineNuxtPlugin((nuxt) => { Now you are ready to prefetch some data in your pages with `onServerPrefetch`. -- Prefetch all the queries that you need with `queryClient.prefetchQuery` or `suspense` +- Prefetch all the queries that you need with `queryClient.query`, `queryClient.infiniteQuery`, or `suspense` ```ts export default defineComponent({ setup() { - const { data, suspense } = useQuery({ + const queryClient = useQueryClient() + const { data } = useQuery({ queryKey: ['test'], queryFn: fetcher, }) @@ -110,7 +111,7 @@ Now you are ready to prefetch some data in your pages with `onServerPrefetch`. - Use `useContext` to get nuxt context - Use `useQueryClient` to get server-side instance of `queryClient` -- Prefetch all the queries that you need with `queryClient.prefetchQuery` or `suspense` +- Prefetch all the queries that you need with `queryClient.query`, `queryClient.infiniteQuery`, or `suspense` - Dehydrate `queryClient` to the `nuxtContext` ```vue @@ -169,7 +170,7 @@ export default defineComponent({ ``` -As demonstrated, it's fine to prefetch some queries and let others fetch on the queryClient. This means you can control what content server renders or not by adding or removing `prefetchQuery` or `suspense` for a specific query. +As demonstrated, it's fine to prefetch some queries and let others fetch on the client. This means you can control what content server renders or not by adding or removing `queryClient.query` or `suspense` for a specific query. ## Using Vite SSR @@ -237,7 +238,7 @@ Then, call VueQuery from any component using Vue's `onServerPrefetch`: Any query with an error is automatically excluded from dehydration. This means that the default behavior is to pretend these queries were never loaded on the server, usually showing a loading state instead, and retrying the queries on the queryClient. This happens regardless of error. -Sometimes this behavior is not desirable, maybe you want to render an error page with a correct status code instead on certain errors or queries. In those cases, use `fetchQuery` and catch any errors to handle those manually. +Sometimes this behavior is not desirable, maybe you want to render an error page with a correct status code instead on certain errors or queries. In those cases, use `queryClient.query` and catch any errors to handle those manually. ### Staleness is measured from when the query was fetched on the server diff --git a/packages/query-core/src/__tests__/queryClient.test-d.tsx b/packages/query-core/src/__tests__/queryClient.test-d.tsx index 4cd092ddd64..a88544075b2 100644 --- a/packages/query-core/src/__tests__/queryClient.test-d.tsx +++ b/packages/query-core/src/__tests__/queryClient.test-d.tsx @@ -1,6 +1,7 @@ import { assertType, describe, expectTypeOf, it } from 'vitest' import { queryKey } from '@tanstack/query-test-utils' import { QueryClient } from '../queryClient' +import { skipToken } from '../utils' import type { MutationFilters, QueryFilters, Updater } from '../utils' import type { Mutation } from '../mutation' import type { Query, QueryState } from '../query' @@ -11,6 +12,7 @@ import type { EnsureQueryDataOptions, FetchInfiniteQueryOptions, InfiniteData, + InfiniteQueryExecuteOptions, MutationOptions, OmitKeyof, QueryKey, @@ -158,7 +160,38 @@ describe('getQueryState', () => { }) }) +describe('fetchQuery', () => { + it('should not allow passing select option', () => { + assertType>([ + { + queryKey: ['key'], + queryFn: () => Promise.resolve('string'), + // @ts-expect-error `select` is not supported on fetchQuery options + select: (data: string) => data.length, + }, + ]) + }) +}) + describe('fetchInfiniteQuery', () => { + it('should not allow passing select option', () => { + assertType>([ + { + queryKey: ['key'], + queryFn: () => Promise.resolve({ count: 1 }), + initialPageParam: 1, + getNextPageParam: () => 2, + // @ts-expect-error `select` is not supported on fetchInfiniteQuery options + select: (data) => ({ + pages: data.pages.map( + (x: unknown) => `count: ${(x as { count: number }).count}`, + ), + pageParams: data.pageParams, + }), + }, + ]) + }) + it('should allow passing pages', async () => { const data = await new QueryClient().fetchInfiniteQuery({ queryKey: queryKey(), @@ -171,7 +204,7 @@ describe('fetchInfiniteQuery', () => { expectTypeOf(data).toEqualTypeOf>() }) - it('should not allow passing getNextPageParam without pages', () => { + it('should allow passing getNextPageParam without pages', () => { assertType>([ { queryKey: ['key'], @@ -195,6 +228,105 @@ describe('fetchInfiniteQuery', () => { }) }) +describe('query', () => { + it('should allow passing select option', () => { + const result = new QueryClient().query({ + queryKey: ['key'], + queryFn: () => Promise.resolve('string'), + select: (data) => data.length, + }) + + expectTypeOf(result).toEqualTypeOf>() + }) + + it('should infer select type with skipToken queryFn', () => { + const result = new QueryClient().query({ + queryKey: ['key'], + queryFn: skipToken, + select: (data: string) => data.length, + }) + + expectTypeOf(result).toEqualTypeOf>() + }) + + it('should infer select type with skipToken queryFn and enabled false', () => { + const result = new QueryClient().query({ + queryKey: ['key'], + queryFn: skipToken, + enabled: false, + select: (data: string) => data.length, + }) + + expectTypeOf(result).toEqualTypeOf>() + }) + + it('should infer select type with skipToken queryFn and enabled true', () => { + const result = new QueryClient().query({ + queryKey: ['key'], + queryFn: skipToken, + enabled: true, + select: (data: string) => data.length, + }) + + expectTypeOf(result).toEqualTypeOf>() + }) +}) + +describe('infiniteQuery', () => { + it('should allow passing select option', () => { + const result = new QueryClient().infiniteQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve({ count: 1 }), + initialPageParam: 1, + getNextPageParam: () => 2, + select: (data) => ({ + pages: data.pages.map( + (x) => `count: ${(x as { count: number }).count}`, + ), + }), + }) + + expectTypeOf(result).toEqualTypeOf }>>() + }) + + it('should allow passing pages', async () => { + const result = await new QueryClient().infiniteQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve({ count: 1 }), + getNextPageParam: () => 1, + initialPageParam: 1, + pages: 5, + }) + + expectTypeOf(result).toEqualTypeOf< + InfiniteData<{ count: number }, number> + >() + }) + + it('should allow passing getNextPageParam without pages', () => { + assertType>([ + { + queryKey: ['key'], + queryFn: () => Promise.resolve({ count: 1 }), + initialPageParam: 1, + getNextPageParam: () => 1, + }, + ]) + }) + + it('should not allow passing pages without getNextPageParam', () => { + assertType>([ + // @ts-expect-error Property 'getNextPageParam' is missing + { + queryKey: ['key'], + queryFn: () => Promise.resolve('string'), + initialPageParam: 1, + pages: 5, + }, + ]) + }) +}) + describe('defaultOptions', () => { it('should have a typed QueryFunctionContext', () => { new QueryClient({ @@ -228,12 +360,27 @@ describe('fully typed usage', () => { // Construct typed arguments // + const infiniteQueryOptions: InfiniteQueryExecuteOptions< + TData, + TError, + InfiniteData + > = { + queryKey: ['key', 'infinite'], + pages: 5, + getNextPageParam: (lastPage) => { + expectTypeOf(lastPage).toEqualTypeOf() + return 0 + }, + initialPageParam: 0, + } + const queryOptions: EnsureQueryDataOptions = { - queryKey: ['key'] as any, + queryKey: ['key', 'query'], } + const fetchInfiniteQueryOptions: FetchInfiniteQueryOptions = { - queryKey: ['key'] as any, + queryKey: ['key', 'infinite'], pages: 5, getNextPageParam: (lastPage) => { expectTypeOf(lastPage).toEqualTypeOf() @@ -241,6 +388,7 @@ describe('fully typed usage', () => { }, initialPageParam: 0, } + const mutationOptions: MutationOptions = {} const queryFilters: QueryFilters> = { @@ -311,11 +459,19 @@ describe('fully typed usage', () => { const fetchedQuery = await queryClient.fetchQuery(queryOptions) expectTypeOf(fetchedQuery).toEqualTypeOf() + const queriedData = await queryClient.query(queryOptions) + expectTypeOf(queriedData).toEqualTypeOf() + queryClient.prefetchQuery(queryOptions) - const infiniteQuery = await queryClient.fetchInfiniteQuery( + const fetchInfiniteQueryResult = await queryClient.fetchInfiniteQuery( fetchInfiniteQueryOptions, ) + expectTypeOf(fetchInfiniteQueryResult).toEqualTypeOf< + InfiniteData + >() + + const infiniteQuery = await queryClient.infiniteQuery(infiniteQueryOptions) expectTypeOf(infiniteQuery).toEqualTypeOf>() const infiniteQueryData = await queryClient.ensureInfiniteQueryData( @@ -450,9 +606,19 @@ describe('fully typed usage', () => { const fetchedQuery = await queryClient.fetchQuery(queryOptions) expectTypeOf(fetchedQuery).toEqualTypeOf() + const queriedData = await queryClient.query(queryOptions) + expectTypeOf(queriedData).toEqualTypeOf() + queryClient.prefetchQuery(queryOptions) - const infiniteQuery = await queryClient.fetchInfiniteQuery( + const fetchInfiniteQueryResult = await queryClient.fetchInfiniteQuery( + fetchInfiniteQueryOptions, + ) + expectTypeOf(fetchInfiniteQueryResult).toEqualTypeOf< + InfiniteData + >() + + const infiniteQuery = await queryClient.infiniteQuery( fetchInfiniteQueryOptions, ) expectTypeOf(infiniteQuery).toEqualTypeOf>() diff --git a/packages/query-core/src/__tests__/queryClient.test.tsx b/packages/query-core/src/__tests__/queryClient.test.tsx index c09db304467..923d113eb54 100644 --- a/packages/query-core/src/__tests__/queryClient.test.tsx +++ b/packages/query-core/src/__tests__/queryClient.test.tsx @@ -8,11 +8,18 @@ import { dehydrate, focusManager, hydrate, + noop, onlineManager, skipToken, } from '..' import { mockOnlineManagerIsOnline } from './utils' -import type { QueryCache, QueryFunction, QueryObserverOptions } from '..' +import type { + InfiniteData, + Query, + QueryCache, + QueryFunction, + QueryObserverOptions, +} from '..' describe('queryClient', () => { let queryClient: QueryClient @@ -449,6 +456,7 @@ describe('queryClient', () => { }) }) + /** @deprecated */ describe('ensureQueryData', () => { it('should return the cached query data if the query is found', async () => { const key = queryKey() @@ -524,6 +532,100 @@ describe('queryClient', () => { }) }) + describe('query with static staleTime', () => { + it('should return the cached query data if the query is found', async () => { + const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve('data')) + + queryClient.setQueryData([key, 'id'], 'bar') + + await expect( + queryClient.query({ + queryKey: [key, 'id'], + queryFn, + staleTime: 'static', + }), + ).resolves.toEqual('bar') + expect(queryFn).not.toHaveBeenCalled() + }) + + it('should return the cached query data if the query is found and cached query data is falsy', async () => { + const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve(0)) + + queryClient.setQueryData([key, 'id'], null) + + await expect( + queryClient.query({ + queryKey: [key, 'id'], + queryFn, + staleTime: 'static', + }), + ).resolves.toEqual(null) + expect(queryFn).not.toHaveBeenCalled() + }) + + it('should call queryFn and return its results if the query is not found', async () => { + const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve('data')) + + await expect( + queryClient.query({ + queryKey: [key], + queryFn, + staleTime: 'static', + }), + ).resolves.toEqual('data') + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should not fetch when initialData is provided', async () => { + const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve('data')) + + await expect( + queryClient.query({ + queryKey: [key, 'id'], + queryFn, + staleTime: 'static', + initialData: 'initial', + }), + ).resolves.toEqual('initial') + + expect(queryFn).not.toHaveBeenCalled() + }) + + it('supports manual background revalidation via a second query call', async () => { + const key = queryKey() + let value = 'data-1' + const queryFn = vi.fn(() => Promise.resolve(value)) + + await expect( + queryClient.query({ + queryKey: key, + queryFn, + staleTime: 'static', + }), + ).resolves.toEqual('data-1') + expect(queryFn).toHaveBeenCalledTimes(1) + + value = 'data-2' + void queryClient + .query({ + queryKey: key, + queryFn, + staleTime: 0, + }) + .catch(noop) + + await vi.advanceTimersByTimeAsync(0) + + expect(queryFn).toHaveBeenCalledTimes(2) + expect(queryClient.getQueryData(key)).toBe('data-2') + }) + }) + + /** @deprecated */ describe('ensureInfiniteQueryData', () => { it('should return the cached query data if the query is found', async () => { const key = queryKey() @@ -584,6 +686,45 @@ describe('queryClient', () => { }) }) + describe('infiniteQuery with static staleTime', () => { + it('should return the cached query data if the query is found', async () => { + const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve('data')) + + queryClient.setQueryData([key, 'id'], { + pages: ['bar'], + pageParams: [0], + }) + + await expect( + queryClient.infiniteQuery({ + queryKey: [key, 'id'], + queryFn, + staleTime: 'static', + initialPageParam: 1, + getNextPageParam: () => undefined, + }), + ).resolves.toEqual({ pages: ['bar'], pageParams: [0] }) + expect(queryFn).not.toHaveBeenCalled() + }) + + it('should fetch the query and return its results if the query is not found', async () => { + const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve('data')) + + await expect( + queryClient.infiniteQuery({ + queryKey: [key, 'id'], + queryFn, + staleTime: 'static', + initialPageParam: 1, + getNextPageParam: () => undefined, + }), + ).resolves.toEqual({ pages: ['data'], pageParams: [1] }) + expect(queryFn).toHaveBeenCalledTimes(1) + }) + }) + describe('getQueriesData', () => { it('should return the query data for all matched queries', () => { const key1 = queryKey() @@ -615,6 +756,7 @@ describe('queryClient', () => { }) }) + /** @deprecated */ describe('fetchQuery', () => { it('should not type-error with strict query key', async () => { type StrictData = 'data' @@ -789,161 +931,937 @@ describe('queryClient', () => { }) }) - describe('fetchInfiniteQuery', () => { + describe('query', () => { it('should not type-error with strict query key', async () => { - type StrictData = string + type StrictData = 'data' type StrictQueryKey = ['strict', ...ReturnType] const key: StrictQueryKey = ['strict', ...queryKey()] - const data = { - pages: ['data'], - pageParams: [0], - } as const - - const fetchFn: QueryFunction = () => - Promise.resolve(data.pages[0]) + const fetchFn: QueryFunction = () => + Promise.resolve('data') await expect( - queryClient.fetchInfiniteQuery< + queryClient.query< StrictData, any, StrictData, - StrictQueryKey, - number - >({ queryKey: key, queryFn: fetchFn, initialPageParam: 0 }), - ).resolves.toEqual(data) + StrictData, + StrictQueryKey + >({ + queryKey: key, + queryFn: fetchFn, + }), + ).resolves.toEqual('data') }) - it('should return infinite query data', async () => { + // https://github.com/tannerlinsley/react-query/issues/652 + it('should not retry by default', async () => { const key = queryKey() - const result = await queryClient.fetchInfiniteQuery({ - queryKey: key, - initialPageParam: 10, - queryFn: ({ pageParam }) => Number(pageParam), - }) - const result2 = queryClient.getQueryData(key) - - const expected = { - pages: [10], - pageParams: [10], - } - expect(result).toEqual(expected) - expect(result2).toEqual(expected) + await expect( + queryClient.query({ + queryKey: key, + queryFn: (): Promise => { + throw new Error('error') + }, + }), + ).rejects.toEqual(new Error('error')) }) - }) - describe('prefetchInfiniteQuery', () => { - it('should not type-error with strict query key', async () => { - type StrictData = 'data' - type StrictQueryKey = ['strict', ...ReturnType] - const key: StrictQueryKey = ['strict', ...queryKey()] + it('should return the cached data on cache hit', async () => { + const key = queryKey() - const fetchFn: QueryFunction = () => - Promise.resolve('data') + const fetchFn = () => Promise.resolve('data') + const first = await queryClient.query({ + queryKey: key, + queryFn: fetchFn, + }) + const second = await queryClient.query({ + queryKey: key, + queryFn: fetchFn, + }) - await queryClient.prefetchInfiniteQuery< - StrictData, - any, - StrictData, - StrictQueryKey, - number - >({ queryKey: key, queryFn: fetchFn, initialPageParam: 0 }) + expect(second).toBe(first) + }) - const result = queryClient.getQueryData(key) + it('should throw when disabled and no cached data exists', async () => { + const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve('data')) + const errorMsg = `Query is disabled and no cached data is available for key: '${JSON.stringify(key)}'` - expect(result).toEqual({ - pages: ['data'], - pageParams: [0], - }) + await expect( + queryClient.query({ + queryKey: key, + queryFn, + enabled: false, + }), + ).rejects.toThrowError(errorMsg) + + expect(queryFn).not.toHaveBeenCalled() }) - it('should return infinite query data', async () => { + it('should return cached data when disabled and apply select', async () => { const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve('fetched-data')) - await queryClient.prefetchInfiniteQuery({ + queryClient.setQueryData(key, 'cached-data') + + const result = await queryClient.query({ queryKey: key, - queryFn: ({ pageParam }) => Number(pageParam), - initialPageParam: 10, + queryFn, + enabled: false, + staleTime: 0, + select: (data) => `${data}-selected`, }) - const result = queryClient.getQueryData(key) - - expect(result).toEqual({ - pages: [10], - pageParams: [10], - }) + expect(result).toBe('cached-data-selected') + expect(queryFn).not.toHaveBeenCalled() }) - it('should prefetch multiple pages', async () => { + it('should throw when skipToken is provided and no cached data exists', async () => { const key = queryKey() + const select = vi.fn((data: unknown) => (data as string).length) - await queryClient.prefetchInfiniteQuery({ - queryKey: key, - queryFn: ({ pageParam }) => String(pageParam), - getNextPageParam: (_lastPage, _pages, lastPageParam) => - lastPageParam + 5, - initialPageParam: 10, - pages: 3, - }) - - const result = queryClient.getQueryData(key) + await expect( + queryClient.query({ + queryKey: key, + queryFn: skipToken, + select, + }), + ).rejects.toThrowError() - expect(result).toEqual({ - pages: ['10', '15', '20'], - pageParams: [10, 15, 20], - }) + expect(select).not.toHaveBeenCalled() }) - it('should stop prefetching if getNextPageParam returns undefined', async () => { + it('should return cached data when skipToken is provided', async () => { const key = queryKey() - let count = 0 - await queryClient.prefetchInfiniteQuery({ + queryClient.setQueryData(key, 'cached-data') + + const result = await queryClient.query({ queryKey: key, - queryFn: ({ pageParam }) => String(pageParam), - getNextPageParam: (_lastPage, _pages, lastPageParam) => { - count++ - return lastPageParam >= 20 ? undefined : lastPageParam + 5 - }, - initialPageParam: 10, - pages: 5, + queryFn: skipToken, + select: (data: unknown) => (data as string).length, }) - const result = queryClient.getQueryData(key) + expect(result).toBe('cached-data'.length) + }) - expect(result).toEqual({ - pages: ['10', '15', '20'], - pageParams: [10, 15, 20], + it('should return cached data when skipToken and enabled false are both provided', async () => { + const key = queryKey() + + queryClient.setQueryData(key, { value: 'cached-data' }) + + const result = await queryClient.query({ + queryKey: key, + queryFn: skipToken, + enabled: false, + select: (data: { value: string }) => data.value.toUpperCase(), }) - // this check ensures we're exiting the fetch loop early - expect(count).toBe(3) + expect(result).toBe('CACHED-DATA') }) - }) - - describe('prefetchQuery', () => { - it('should not type-error with strict query key', async () => { - type StrictData = 'data' - type StrictQueryKey = ['strict', ...ReturnType] - const key: StrictQueryKey = ['strict', ...queryKey()] - const fetchFn: QueryFunction = () => - Promise.resolve('data') + it('should throw when enabled is true and skipToken are provided with no cached data', async () => { + await expect( + queryClient.query({ + queryKey: queryKey(), + queryFn: skipToken, + enabled: true, + }), + ).rejects.toThrowError() + }) - await queryClient.prefetchQuery< - StrictData, - any, - StrictData, - StrictQueryKey - >({ queryKey: key, queryFn: fetchFn }) + it('should return cached data when enabled is false and skipToken are provided', async () => { + const key1 = queryKey() + queryClient.setQueryData(key1, { value: 'cached-data' }) - const result = queryClient.getQueryData(key) + const booleanDisabledResult = await queryClient.query({ + queryKey: key1, + queryFn: skipToken, + enabled: false, + select: (data: { value: string }) => data.value.length, + }) - expect(result).toEqual('data') + expect(booleanDisabledResult).toBe('cached-data'.length) }) - it('should return undefined when an error is thrown', async () => { + it('should return cached data when enabled callback returns false even if queryFn would return different data', async () => { + const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve('fetched-data')) + + queryClient.setQueryData(key, 'cached-data') + + const result = await queryClient.query({ + queryKey: key, + queryFn, + enabled: () => false, + }) + + expect(result).toBe('cached-data') + expect(queryFn).not.toHaveBeenCalled() + }) + + it('should fetch when enabled callback returns true and cache is stale', async () => { + const key = queryKey() + + queryClient.setQueryData(key, 'old-data') + + await vi.advanceTimersByTimeAsync(1) + + const queryFn = vi.fn(() => Promise.resolve('new-data')) + + const result = await queryClient.query({ + queryKey: key, + queryFn, + enabled: () => true, + staleTime: 0, + }) + + expect(result).toBe('new-data') + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should read from cache with static staleTime even if invalidated', async () => { + const key = queryKey() + + const fetchFn = vi.fn(() => Promise.resolve({ data: 'data' })) + const first = await queryClient.query({ + queryKey: key, + queryFn: fetchFn, + staleTime: 'static', + }) + + expect(first.data).toBe('data') + expect(fetchFn).toHaveBeenCalledTimes(1) + + await queryClient.invalidateQueries({ + queryKey: key, + refetchType: 'none', + }) + + const second = await queryClient.query({ + queryKey: key, + queryFn: fetchFn, + staleTime: 'static', + }) + + expect(fetchFn).toHaveBeenCalledTimes(1) + + expect(second).toBe(first) + }) + + it('should be able to fetch when garbage collection time is set to 0 and then be removed', async () => { + const key1 = queryKey() + const promise = queryClient.query({ + queryKey: key1, + queryFn: () => sleep(10).then(() => 1), + gcTime: 0, + }) + await vi.advanceTimersByTimeAsync(10) + await expect(promise).resolves.toEqual(1) + await vi.advanceTimersByTimeAsync(1) + expect(queryClient.getQueryData(key1)).toEqual(undefined) + }) + + it('should keep a query in cache if garbage collection time is Infinity', async () => { + const key1 = queryKey() + const promise = queryClient.query({ + queryKey: key1, + queryFn: () => sleep(10).then(() => 1), + gcTime: Infinity, + }) + await vi.advanceTimersByTimeAsync(10) + const result2 = queryClient.getQueryData(key1) + await expect(promise).resolves.toEqual(1) + expect(result2).toEqual(1) + }) + + it('should not force fetch', async () => { + const key = queryKey() + + queryClient.setQueryData(key, 'og') + const fetchFn = () => Promise.resolve('new') + const first = await queryClient.query({ + queryKey: key, + queryFn: fetchFn, + initialData: 'initial', + staleTime: 100, + }) + expect(first).toBe('og') + }) + + it('should only fetch if the data is older then the given stale time', async () => { + const key = queryKey() + + let count = 0 + const queryFn = () => ++count + + queryClient.setQueryData(key, count) + const firstPromise = queryClient.query({ + queryKey: key, + queryFn, + staleTime: 100, + }) + await expect(firstPromise).resolves.toBe(0) + await vi.advanceTimersByTimeAsync(10) + const secondPromise = queryClient.query({ + queryKey: key, + queryFn, + staleTime: 10, + }) + await expect(secondPromise).resolves.toBe(1) + const thirdPromise = queryClient.query({ + queryKey: key, + queryFn, + staleTime: 10, + }) + await expect(thirdPromise).resolves.toBe(1) + await vi.advanceTimersByTimeAsync(10) + const fourthPromise = queryClient.query({ + queryKey: key, + queryFn, + staleTime: 10, + }) + await expect(fourthPromise).resolves.toBe(2) + }) + + it('should evaluate staleTime when provided as a function', async () => { + const key = queryKey() + const staleTime = vi.fn(() => 0) + + queryClient.setQueryData(key, 'old-data') + + await vi.advanceTimersByTimeAsync(1) + + const queryFn = vi.fn(() => Promise.resolve('new-data')) + + const result = await queryClient.query({ + queryKey: key, + queryFn, + staleTime, + }) + + expect(result).toBe('new-data') + expect(queryFn).toHaveBeenCalledTimes(1) + expect(staleTime).toHaveBeenCalledTimes(1) + }) + + it('should allow new meta', async () => { + const key = queryKey() + + const first = await queryClient.query({ + queryKey: key, + queryFn: ({ meta }) => Promise.resolve(meta), + meta: { + foo: true, + }, + }) + expect(first).toStrictEqual({ foo: true }) + + const second = await queryClient.query({ + queryKey: key, + queryFn: ({ meta }) => Promise.resolve(meta), + meta: { + foo: false, + }, + }) + expect(second).toStrictEqual({ foo: false }) + }) + + it('should fetch when enabled is true and cache is stale', async () => { + const key = queryKey() + + queryClient.setQueryData(key, 'old-data') + + await vi.advanceTimersByTimeAsync(1) + + const queryFn = vi.fn(() => Promise.resolve('new-data')) + + const result = await queryClient.query({ + queryKey: key, + queryFn, + enabled: true, + staleTime: 0, + }) + + expect(result).toBe('new-data') + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should propagate errors', async () => { + const key = queryKey() + + await expect( + queryClient.query({ + queryKey: key, + queryFn: (): Promise => { + throw new Error('error') + }, + }), + ).rejects.toEqual(new Error('error')) + }) + + it('should apply select when data is fresh in cache', async () => { + const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve('fetched-data')) + + queryClient.setQueryData(key, 'cached-data') + + const result = await queryClient.query({ + queryKey: key, + queryFn, + staleTime: Infinity, + select: (data) => `${data}-selected`, + }) + + expect(result).toBe('cached-data-selected') + expect(queryFn).not.toHaveBeenCalled() + }) + + it('should apply select to freshly fetched data', async () => { + const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve({ value: 'fetched-data' })) + + const result = await queryClient.query({ + queryKey: key, + queryFn, + select: (data) => data.value.toUpperCase(), + }) + + expect(result).toBe('FETCHED-DATA') + expect(queryFn).toHaveBeenCalledTimes(1) + }) + }) + + /** @deprecated */ + describe('fetchInfiniteQuery', () => { + it('should not type-error with strict query key', async () => { + type StrictData = string + type StrictQueryKey = ['strict', ...ReturnType] + const key: StrictQueryKey = ['strict', ...queryKey()] + + const data = { + pages: ['data'], + pageParams: [0], + } as const + + const fetchFn: QueryFunction = () => + Promise.resolve(data.pages[0]) + + await expect( + queryClient.fetchInfiniteQuery< + StrictData, + any, + StrictData, + StrictQueryKey, + number + >({ queryKey: key, queryFn: fetchFn, initialPageParam: 0 }), + ).resolves.toEqual(data) + }) + + it('should return infinite query data', async () => { + const key = queryKey() + const result = await queryClient.fetchInfiniteQuery({ + queryKey: key, + initialPageParam: 10, + queryFn: ({ pageParam }) => Number(pageParam), + }) + const result2 = queryClient.getQueryData(key) + + const expected = { + pages: [10], + pageParams: [10], + } + + expect(result).toEqual(expected) + expect(result2).toEqual(expected) + }) + }) + + describe('infiniteQuery', () => { + it('should not type-error with strict query key', async () => { + type StrictData = string + type StrictQueryKey = ['strict', ...ReturnType] + const key: StrictQueryKey = ['strict', ...queryKey()] + + const data = { + pages: ['data'], + pageParams: [0], + } as const + + const fetchFn: QueryFunction = () => + Promise.resolve(data.pages[0]) + + await expect( + queryClient.infiniteQuery< + StrictData, + any, + StrictData, + StrictQueryKey, + number + >({ queryKey: key, queryFn: fetchFn, initialPageParam: 0 }), + ).resolves.toEqual(data) + }) + + it('should return infinite query data', async () => { + const key = queryKey() + const result = await queryClient.infiniteQuery({ + queryKey: key, + initialPageParam: 10, + queryFn: ({ pageParam }) => Number(pageParam), + }) + const result2 = queryClient.getQueryData(key) + + const expected = { + pages: [10], + pageParams: [10], + } + + expect(result).toEqual(expected) + expect(result2).toEqual(expected) + }) + + it('should throw when disabled and no cached data exists', async () => { + const key = queryKey() + const queryFn = vi.fn(({ pageParam }: { pageParam: number }) => + Promise.resolve(pageParam), + ) + const errorMsg = `Query is disabled and no cached data is available for key: '${JSON.stringify(key)}'` + + await expect( + queryClient.infiniteQuery({ + queryKey: key, + queryFn, + initialPageParam: 0, + enabled: false, + }), + ).rejects.toThrow(errorMsg) + + expect(queryFn).not.toHaveBeenCalled() + }) + + it('should return cached data when disabled and apply select', async () => { + const key = queryKey() + const queryFn = vi.fn(({ pageParam }: { pageParam: number }) => + Promise.resolve(`'fetched-${String(pageParam)}`), + ) + + queryClient.setQueryData(key, { + pages: ['cached-page'], + pageParams: [0], + }) + + const result = await queryClient.infiniteQuery({ + queryKey: key, + queryFn, + initialPageParam: 0, + enabled: false, + select: (data) => data.pages.map((page) => `${page}-selected`), + }) + + expect(result).toEqual(['cached-page-selected']) + expect(queryFn).not.toHaveBeenCalled() + }) + + it('should return cached data when skipToken is provided', async () => { + const key = queryKey() + + queryClient.setQueryData(key, { + pages: ['page-1'], + pageParams: [0], + }) + + const result = await queryClient.infiniteQuery({ + queryKey: key, + queryFn: skipToken, + initialPageParam: 0, + }) + + expect(result).toEqual({ + pages: ['page-1'], + pageParams: [0], + }) + }) + + it('should throw when skipToken is provided and no cached data exists', async () => { + const key = queryKey() + const select = vi.fn( + (data: { pages: Array }) => data.pages.length, + ) + + await expect( + queryClient.infiniteQuery({ + queryKey: key, + queryFn: skipToken, + initialPageParam: 0, + select, + }), + ).rejects.toThrowError() + + expect(select).not.toHaveBeenCalled() + }) + + it('should throw when enabled is true and skipToken are provided with no cached data', async () => { + await expect( + queryClient.infiniteQuery({ + queryKey: queryKey(), + queryFn: skipToken, + initialPageParam: 0, + enabled: true, + }), + ).rejects.toThrowError() + }) + + it('should return cached data when enabled resolves false and skipToken are provided', async () => { + const key = queryKey() + + queryClient.setQueryData(key, { + pages: [{ value: 'cached-page' }], + pageParams: [0], + }) + + const result = await queryClient.infiniteQuery({ + queryKey: key, + queryFn: skipToken, + initialPageParam: 0, + enabled: () => false, + select: (data: { pages: Array<{ value: string }> }) => + data.pages[0]?.value.length, + }) + + expect(result).toBe('cached-page'.length) + }) + + it('should fetch when enabled callback returns true and cache is stale', async () => { + const key = queryKey() + + queryClient.setQueryData(key, { + pages: ['old-page'], + pageParams: [0], + }) + + await vi.advanceTimersByTimeAsync(1) + + const queryFn = vi.fn(({ pageParam }: { pageParam: number }) => + Promise.resolve(`new-page-${String(pageParam)}`), + ) + + const result = await queryClient.infiniteQuery({ + queryKey: key, + queryFn, + initialPageParam: 0, + enabled: () => true, + staleTime: 0, + }) + + expect(result).toEqual({ + pages: ['new-page-0'], + pageParams: [0], + }) + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should evaluate staleTime callback and refetch when it returns stale', async () => { + const key = queryKey() + + queryClient.setQueryData(key, { + pages: [{ value: 'old-page', staleTime: 0 }], + pageParams: [0], + }) + + await vi.advanceTimersByTimeAsync(1) + + const queryFn = vi.fn(({ pageParam }: { pageParam: number }) => + Promise.resolve({ + value: `new-page-${String(pageParam)}`, + staleTime: 0, + }), + ) + const staleTimeSpy = vi.fn() + const staleTime = ( + query: Query< + { value: string; staleTime: number }, + Error, + InfiniteData<{ value: string; staleTime: number }, number> + >, + ) => { + staleTimeSpy() + return query.state.data?.pages[0]?.staleTime ?? 0 + } + + const result = await queryClient.infiniteQuery({ + queryKey: key, + queryFn, + initialPageParam: 0, + staleTime, + }) + + expect(result).toEqual({ + pages: [{ value: 'new-page-0', staleTime: 0 }], + pageParams: [0], + }) + expect(staleTimeSpy).toHaveBeenCalled() + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should read from cache with static staleTime even if invalidated', async () => { + const key = queryKey() + + const queryFn = vi.fn(({ pageParam }: { pageParam: number }) => + Promise.resolve({ value: `fetched-${String(pageParam)}` }), + ) + const first = await queryClient.infiniteQuery({ + queryKey: key, + queryFn, + initialPageParam: 0, + staleTime: 'static', + }) + + expect(first).toEqual({ + pages: [{ value: 'fetched-0' }], + pageParams: [0], + }) + expect(queryFn).toHaveBeenCalledTimes(1) + + await queryClient.invalidateQueries({ + queryKey: key, + refetchType: 'none', + }) + + const second = await queryClient.infiniteQuery({ + queryKey: key, + queryFn, + initialPageParam: 0, + staleTime: 'static', + }) + + expect(queryFn).toHaveBeenCalledTimes(1) + expect(second).toBe(first) + }) + + it('should apply select to infinite query data', async () => { + const key = queryKey() + + const result = await queryClient.infiniteQuery({ + queryKey: key, + initialPageParam: 10, + queryFn: ({ pageParam }) => Number(pageParam), + select: (data) => data.pages.map((page) => page * 2), + }) + + expect(result).toEqual([20]) + }) + }) + + /** @deprecated */ + describe('prefetchInfiniteQuery', () => { + it('should not type-error with strict query key', async () => { + type StrictData = 'data' + type StrictQueryKey = ['strict', ...ReturnType] + const key: StrictQueryKey = ['strict', ...queryKey()] + + const fetchFn: QueryFunction = () => + Promise.resolve('data') + + await queryClient.prefetchInfiniteQuery< + StrictData, + any, + StrictData, + StrictQueryKey, + number + >({ queryKey: key, queryFn: fetchFn, initialPageParam: 0 }) + + const result = queryClient.getQueryData(key) + + expect(result).toEqual({ + pages: ['data'], + pageParams: [0], + }) + }) + + it('should return infinite query data', async () => { + const key = queryKey() + + await queryClient.prefetchInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => Number(pageParam), + initialPageParam: 10, + }) + + const result = queryClient.getQueryData(key) + + expect(result).toEqual({ + pages: [10], + pageParams: [10], + }) + }) + + it('should prefetch multiple pages', async () => { + const key = queryKey() + + await queryClient.prefetchInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => String(pageParam), + getNextPageParam: (_lastPage, _pages, lastPageParam) => + lastPageParam + 5, + initialPageParam: 10, + pages: 3, + }) + + const result = queryClient.getQueryData(key) + + expect(result).toEqual({ + pages: ['10', '15', '20'], + pageParams: [10, 15, 20], + }) + }) + + it('should stop prefetching if getNextPageParam returns undefined', async () => { + const key = queryKey() + let count = 0 + + await queryClient.prefetchInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => String(pageParam), + getNextPageParam: (_lastPage, _pages, lastPageParam) => { + count++ + return lastPageParam >= 20 ? undefined : lastPageParam + 5 + }, + initialPageParam: 10, + pages: 5, + }) + + const result = queryClient.getQueryData(key) + + expect(result).toEqual({ + pages: ['10', '15', '20'], + pageParams: [10, 15, 20], + }) + + // this check ensures we're exiting the fetch loop early + expect(count).toBe(3) + }) + }) + + describe('infiniteQuery used for prefetching', () => { + it('should not type-error with strict query key', async () => { + type StrictData = 'data' + type StrictQueryKey = ['strict', ...ReturnType] + const key: StrictQueryKey = ['strict', ...queryKey()] + + const fetchFn: QueryFunction = () => + Promise.resolve('data') + + await queryClient + .infiniteQuery({ + queryKey: key, + queryFn: fetchFn, + initialPageParam: 0, + }) + .catch(noop) + + const result = queryClient.getQueryData(key) + + expect(result).toEqual({ + pages: ['data'], + pageParams: [0], + }) + }) + + it('should return infinite query data', async () => { + const key = queryKey() + + await queryClient + .infiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => Number(pageParam), + initialPageParam: 10, + }) + .catch(noop) + + const result = queryClient.getQueryData(key) + + expect(result).toEqual({ + pages: [10], + pageParams: [10], + }) + }) + + it('should prefetch multiple pages', async () => { + const key = queryKey() + + await queryClient + .infiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => String(pageParam), + getNextPageParam: (_lastPage, _pages, lastPageParam) => + lastPageParam + 5, + initialPageParam: 10, + pages: 3, + }) + .catch(noop) + + const result = queryClient.getQueryData(key) + + expect(result).toEqual({ + pages: ['10', '15', '20'], + pageParams: [10, 15, 20], + }) + }) + + it('should stop prefetching if getNextPageParam returns undefined', async () => { + const key = queryKey() + let count = 0 + + await queryClient + .infiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => String(pageParam), + getNextPageParam: (_lastPage, _pages, lastPageParam) => { + count++ + return lastPageParam >= 20 ? undefined : lastPageParam + 5 + }, + initialPageParam: 10, + pages: 5, + }) + .catch(noop) + + const result = queryClient.getQueryData(key) + + expect(result).toEqual({ + pages: ['10', '15', '20'], + pageParams: [10, 15, 20], + }) + + // this check ensures we're exiting the fetch loop early + expect(count).toBe(3) + }) + }) + + /** @deprecated */ + describe('prefetchQuery', () => { + it('should not type-error with strict query key', async () => { + type StrictData = 'data' + type StrictQueryKey = ['strict', ...ReturnType] + const key: StrictQueryKey = ['strict', ...queryKey()] + + const fetchFn: QueryFunction = () => + Promise.resolve('data') + + await queryClient.prefetchQuery< + StrictData, + any, + StrictData, + StrictQueryKey + >({ queryKey: key, queryFn: fetchFn }) + + const result = queryClient.getQueryData(key) + + expect(result).toEqual('data') + }) + + it('should return undefined when an error is thrown', async () => { const key = queryKey() const result = await queryClient.prefetchQuery({ @@ -971,6 +1889,59 @@ describe('queryClient', () => { }) }) + describe('query used for prefetching', () => { + it('should not type-error with strict query key', async () => { + type StrictData = 'data' + type StrictQueryKey = ['strict', ...ReturnType] + const key: StrictQueryKey = ['strict', ...queryKey()] + + const fetchFn: QueryFunction = () => + Promise.resolve('data') + + await queryClient + .query({ + queryKey: key, + queryFn: fetchFn, + }) + .catch(noop) + + const result = queryClient.getQueryData(key) + + expect(result).toEqual('data') + }) + + it('should resolve to undefined when error is caught with noop', async () => { + const key = queryKey() + + const result = await queryClient + .query({ + queryKey: key, + queryFn: (): Promise => { + throw new Error('error') + }, + retry: false, + }) + .catch(noop) + + expect(result).toBeUndefined() + }) + + it('should be garbage collected after gcTime if unused', async () => { + const key = queryKey() + + await queryClient + .query({ + queryKey: key, + queryFn: () => 'data', + gcTime: 10, + }) + .catch(noop) + expect(queryCache.find({ queryKey: key })).toBeDefined() + await vi.advanceTimersByTimeAsync(15) + expect(queryCache.find({ queryKey: key })).not.toBeDefined() + }) + }) + describe('removeQueries', () => { it('should not crash when exact is provided', async () => { const key = queryKey() diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index f448a068f2f..4898ec53661 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -4,6 +4,7 @@ import { hashQueryKeyByOptions, noop, partialMatchKey, + resolveQueryBoolean, resolveStaleTime, skipToken, } from './utils' @@ -24,6 +25,7 @@ import type { InferDataFromTag, InferErrorFromTag, InfiniteData, + InfiniteQueryExecuteOptions, InvalidateOptions, InvalidateQueryFilters, MutationKey, @@ -32,6 +34,7 @@ import type { NoInfer, OmitKeyof, QueryClientConfig, + QueryExecuteOptions, QueryKey, QueryObserverOptions, QueryOptions, @@ -136,6 +139,9 @@ export class QueryClient { .data } + /** + * @deprecated Use queryClient.query({ ...options, staleTime: 'static' }) instead. This method will be removed in the next major version. + */ ensureQueryData< TQueryFnData, TError = DefaultError, @@ -337,6 +343,72 @@ export class QueryClient { return Promise.all(promises).then(noop) } + async query< + TQueryFnData, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = never, + >( + options: QueryExecuteOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey, + TPageParam + >, + ): Promise { + const defaultedOptions = this.defaultQueryOptions(options) + const disabledErrorMessage = `Query is disabled and no cached data is available for key: '${defaultedOptions.queryHash}'` + + // https://github.com/tannerlinsley/react-query/issues/652 + if (defaultedOptions.retry === undefined) { + defaultedOptions.retry = false + } + + const cachedQuery = this.#queryCache.get( + defaultedOptions.queryHash, + ) + + if ( + typeof defaultedOptions.enabled !== 'function' && + defaultedOptions.enabled === false && + cachedQuery?.state.data === undefined + ) { + throw new Error(disabledErrorMessage) + } + + const query = this.#queryCache.build(this, defaultedOptions) + const isEnabled = + resolveQueryBoolean(defaultedOptions.enabled, query) !== false + + if (!isEnabled && query.state.data === undefined) { + throw new Error(disabledErrorMessage) + } + + const isStale = query.isStaleByTime( + resolveStaleTime(defaultedOptions.staleTime, query), + ) + + const queryData = + isStale && isEnabled + ? await query.fetch(defaultedOptions) + : (query.state.data as TQueryData) + + const select = defaultedOptions.select + + if (select) { + return select(queryData) + } + + return queryData as unknown as TData + } + + /** + * @deprecated Use queryClient.query(options) instead. This method will be removed in the next major version. + */ fetchQuery< TQueryFnData, TError = DefaultError, @@ -368,6 +440,9 @@ export class QueryClient { : Promise.resolve(query.state.data as TData) } + /** + * @deprecated Use queryClient.query(options) instead. You can swallow errors with `.catch(noop)`. This method will be removed in the next major version. + */ prefetchQuery< TQueryFnData = unknown, TError = DefaultError, @@ -379,6 +454,32 @@ export class QueryClient { return this.fetchQuery(options).then(noop).catch(noop) } + infiniteQuery< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, + >( + options: InfiniteQueryExecuteOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, + ): Promise< + Array extends Array> + ? InfiniteData + : TData + > { + options._type = 'infinite' + return this.query(options as any) + } + + /** + * @deprecated Use queryClient.infiniteQuery(options) instead. This method will be removed in the next major version. + */ fetchInfiniteQuery< TQueryFnData, TError = DefaultError, @@ -398,6 +499,9 @@ export class QueryClient { return this.fetchQuery(options as any) } + /** + * @deprecated Use queryClient.infiniteQuery(options) instead. You can swallow errors with `.catch(noop)`. This method will be removed in the next major version. + */ prefetchInfiniteQuery< TQueryFnData, TError = DefaultError, @@ -416,6 +520,9 @@ export class QueryClient { return this.fetchInfiniteQuery(options).then(noop).catch(noop) } + /** + * @deprecated Use queryClient.infiniteQuery({ ...options, staleTime: 'static' }) instead. This method will be removed in the next major version. + */ ensureInfiniteQueryData< TQueryFnData, TError = DefaultError, diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index 6cfe16484e5..6295e4827b6 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -488,6 +488,32 @@ export type DefaultedInfiniteQueryObserverOptions< 'throwOnError' | 'refetchOnReconnect' | 'queryHash' > +export interface QueryExecuteOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = never, +> extends WithRequired< + QueryOptions, + 'queryKey' +> { + initialPageParam?: never + /** + * Set this to `false` or a function that returns `false` to disable fetching. + * If cached data exists, it will be returned. + */ + enabled?: QueryBooleanOption + select?: (data: TQueryData) => TData + /** + * The time in milliseconds after data is considered stale. + * If the data is fresh it will be returned from the cache. + */ + staleTime?: StaleTimeFunction +} + +/** @deprecated */ export interface FetchQueryOptions< TQueryFnData = unknown, TError = DefaultError, @@ -506,6 +532,7 @@ export interface FetchQueryOptions< staleTime?: StaleTimeFunction } +/** @deprecated */ export interface EnsureQueryDataOptions< TQueryFnData = unknown, TError = DefaultError, @@ -522,6 +549,7 @@ export interface EnsureQueryDataOptions< revalidateIfStale?: boolean } +/** @deprecated */ export type EnsureInfiniteQueryDataOptions< TQueryFnData = unknown, TError = DefaultError, @@ -538,13 +566,34 @@ export type EnsureInfiniteQueryDataOptions< revalidateIfStale?: boolean } -type FetchInfiniteQueryPages = +type InfiniteQueryPages = | { pages?: never } | { pages: number getNextPageParam: GetNextPageParamFunction } +export type InfiniteQueryExecuteOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> = Omit< + QueryExecuteOptions< + TQueryFnData, + TError, + TData, + InfiniteData, + TQueryKey, + TPageParam + >, + 'initialPageParam' +> & + InitialPageParam & + InfiniteQueryPages + +/** @deprecated */ export type FetchInfiniteQueryOptions< TQueryFnData = unknown, TError = DefaultError, @@ -562,7 +611,7 @@ export type FetchInfiniteQueryOptions< 'initialPageParam' > & InitialPageParam & - FetchInfiniteQueryPages + InfiniteQueryPages export interface ResultOptions { throwOnError?: boolean diff --git a/packages/vue-query/src/__tests__/infiniteQueryOptions.test-d.ts b/packages/vue-query/src/__tests__/infiniteQueryOptions.test-d.ts index 2b15cb7bfed..5e09f5c0b44 100644 --- a/packages/vue-query/src/__tests__/infiniteQueryOptions.test-d.ts +++ b/packages/vue-query/src/__tests__/infiniteQueryOptions.test-d.ts @@ -1,6 +1,6 @@ import { assertType, describe, expectTypeOf, it } from 'vitest' import { dataTagSymbol } from '@tanstack/query-core' -import { reactive } from 'vue-demi' +import { reactive, unref } from 'vue-demi' import { queryKey } from '@tanstack/query-test-utils' import { infiniteQueryOptions } from '../infiniteQueryOptions' import { QueryClient } from '../queryClient' @@ -49,6 +49,41 @@ describe('infiniteQueryOptions', () => { InfiniteData | undefined >() }) + it('should work when passed to infiniteQuery', async () => { + const options = infiniteQueryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve('string'), + getNextPageParam: () => 1, + initialPageParam: 1, + }) + + const data = await new QueryClient().infiniteQuery({ + ...unref(options), + enabled: true, + staleTime: 0, + pages: 1, + }) + + expectTypeOf(data).toEqualTypeOf>() + }) + it('should work when passed to infiniteQuery with select', async () => { + const options = infiniteQueryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve('string'), + getNextPageParam: () => 1, + initialPageParam: 1, + select: (data) => data.pages, + }) + + const data = await new QueryClient().infiniteQuery({ + ...unref(options), + enabled: true, + staleTime: 0, + pages: 1, + }) + + expectTypeOf(data).toEqualTypeOf>() + }) it('should tag the queryKey with the result type of the QueryFn', () => { const key = queryKey() const { queryKey: tagged } = infiniteQueryOptions({ diff --git a/packages/vue-query/src/__tests__/queryClient.test-d.ts b/packages/vue-query/src/__tests__/queryClient.test-d.ts index cd3af0d5618..cab8f59c007 100644 --- a/packages/vue-query/src/__tests__/queryClient.test-d.ts +++ b/packages/vue-query/src/__tests__/queryClient.test-d.ts @@ -1,4 +1,5 @@ import { assertType, describe, expectTypeOf, it } from 'vitest' +import { skipToken } from '@tanstack/query-core' import { queryKey } from '@tanstack/query-test-utils' import { QueryClient } from '../queryClient' import type { DataTag, InfiniteData } from '@tanstack/query-core' @@ -151,3 +152,108 @@ describe('fetchInfiniteQuery', () => { ]) }) }) + +describe('query', () => { + it('should return the selected type', () => { + const result = new QueryClient().query({ + queryKey: ['key'], + queryFn: () => Promise.resolve('string'), + select: (data) => data.length, + }) + + expectTypeOf(result).toEqualTypeOf>() + }) + + it('should infer select type with skipToken', () => { + const result = new QueryClient().query({ + queryKey: ['key'], + queryFn: skipToken, + select: (data: string) => data.length, + }) + + expectTypeOf(result).toEqualTypeOf>() + }) + + it('should infer select type with skipToken and enabled false', () => { + const result = new QueryClient().query({ + queryKey: ['key'], + queryFn: skipToken, + enabled: false, + select: (data: string) => data.length, + }) + + expectTypeOf(result).toEqualTypeOf>() + }) + + it('should infer select type with skipToken and enabled true', () => { + const result = new QueryClient().query({ + queryKey: ['key'], + queryFn: skipToken, + enabled: true, + select: (data: string) => data.length, + }) + + expectTypeOf(result).toEqualTypeOf>() + }) +}) + +describe('infiniteQuery', () => { + it('should return infinite data', async () => { + const data = await new QueryClient().infiniteQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve('string'), + getNextPageParam: () => 1, + initialPageParam: 1, + }) + + expectTypeOf(data).toEqualTypeOf>() + }) + + it('should return the selected type', () => { + const result = new QueryClient().infiniteQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve({ count: 1 }), + getNextPageParam: () => 2, + initialPageParam: 1, + select: (data) => data.pages.map((page) => page.count), + }) + + expectTypeOf(result).toEqualTypeOf>>() + }) + + it('should allow passing pages with getNextPageParam', () => { + assertType>([ + { + queryKey: ['key'], + queryFn: () => Promise.resolve('string'), + initialPageParam: 1, + getNextPageParam: () => 1, + pages: 5, + }, + ]) + }) + + it('should not allow passing pages without getNextPageParam', () => { + assertType>([ + // @ts-expect-error Property 'getNextPageParam' is missing + { + queryKey: ['key'], + queryFn: () => Promise.resolve('string'), + initialPageParam: 1, + pages: 5, + }, + ]) + }) + + it('should preserve page param inference', () => { + new QueryClient().infiniteQuery({ + queryKey: ['key'], + queryFn: ({ pageParam }) => { + expectTypeOf(pageParam).toEqualTypeOf() + return Promise.resolve(pageParam.toString()) + }, + initialPageParam: 1, + getNextPageParam: () => undefined, + }) + }) +}) diff --git a/packages/vue-query/src/__tests__/queryClient.test.ts b/packages/vue-query/src/__tests__/queryClient.test.ts index be6023c17b2..66bd11bc336 100644 --- a/packages/vue-query/src/__tests__/queryClient.test.ts +++ b/packages/vue-query/src/__tests__/queryClient.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { ref } from 'vue-demi' +import { ref, unref } from 'vue-demi' import { QueryClient as QueryClientOrigin } from '@tanstack/query-core' import { QueryClient } from '../queryClient' import { infiniteQueryOptions } from '../infiniteQueryOptions' @@ -338,6 +338,41 @@ describe('QueryCache', () => { }) }) + describe('query', () => { + it('should properly unwrap queryKey', () => { + const queryClient = new QueryClient() + + queryClient.query({ + queryKey: queryKeyRef, + }) + + expect(QueryClientOrigin.prototype.query).toBeCalledWith({ + queryKey: queryKeyUnref, + }) + }) + + it('should properly unwrap enabled, staleTime, and select', () => { + const queryClient = new QueryClient() + const enabled = () => false + const staleTime = () => 1000 + const select = (data: string) => data.length + + queryClient.query({ + queryKey: queryKeyRef, + enabled: ref(enabled), + staleTime: ref(staleTime), + select: ref(select), + }) + + expect(QueryClientOrigin.prototype.query).toBeCalledWith({ + queryKey: queryKeyUnref, + enabled, + staleTime, + select, + }) + }) + }) + describe('prefetchQuery', () => { it('should properly unwrap parameters', () => { const queryClient = new QueryClient() @@ -387,6 +422,59 @@ describe('QueryCache', () => { }) }) + describe('infiniteQuery', () => { + it('should properly unwrap queryKey, initialPageParam, pages, and select', () => { + const queryClient = new QueryClient() + const getNextPageParam = () => 1 + const select = (data: { pages: Array }) => data.pages.length + + queryClient.infiniteQuery({ + queryKey: queryKeyRef, + initialPageParam: ref(0), + pages: ref(2), + getNextPageParam: ref(getNextPageParam), + select: ref(select), + }) + + expect(QueryClientOrigin.prototype.infiniteQuery).toBeCalledWith( + expect.objectContaining({ + queryKey: queryKeyUnref, + initialPageParam: 0, + pages: 2, + getNextPageParam, + select, + }), + ) + }) + + it('should properly unwrap getNextPageParam when using infiniteQueryOptions', () => { + const queryClient = new QueryClient() + const getNextPageParam = () => 12 + + const options = infiniteQueryOptions({ + queryKey: queryKeyRef, + initialPageParam: ref(0), + getNextPageParam: ref(getNextPageParam), + }) + + queryClient.infiniteQuery({ + ...unref(options), + enabled: true, + staleTime: 0, + pages: 1, + }) + + expect(QueryClientOrigin.prototype.infiniteQuery).toBeCalledWith( + expect.objectContaining({ + queryKey: queryKeyUnref, + initialPageParam: 0, + pages: 1, + getNextPageParam, + }), + ) + }) + }) + describe('prefetchInfiniteQuery', () => { it('should properly unwrap parameters', () => { const queryClient = new QueryClient() diff --git a/packages/vue-query/src/__tests__/queryOptions.test-d.ts b/packages/vue-query/src/__tests__/queryOptions.test-d.ts index 481dd808fc5..25a696b807f 100644 --- a/packages/vue-query/src/__tests__/queryOptions.test-d.ts +++ b/packages/vue-query/src/__tests__/queryOptions.test-d.ts @@ -39,6 +39,25 @@ describe('queryOptions', () => { const { data } = reactive(useQuery(options)) expectTypeOf(data).toEqualTypeOf() }) + it('should work when passed to query', async () => { + const options = queryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + }) + + const data = await new QueryClient().query(options) + expectTypeOf(data).toEqualTypeOf() + }) + it('should work when passed to query with select', async () => { + const options = queryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + select: (data) => data.toString(), + }) + + const data = await new QueryClient().query(options) + expectTypeOf(data).toEqualTypeOf() + }) it('should tag the queryKey with the result type of the QueryFn', () => { const key = queryKey() const { queryKey: tagged } = queryOptions({ @@ -132,10 +151,13 @@ describe('queryOptions', () => { // Should not error const data = queryClient.invalidateQueries(options) // Should not error - const data2 = queryClient.fetchQuery(options) + const data2 = queryClient.query(options) + // Should not error + const data3 = queryClient.fetchQuery(options) expectTypeOf(data).toEqualTypeOf>() expectTypeOf(data2).toEqualTypeOf>() + expectTypeOf(data3).toEqualTypeOf>() }) it('TData should always be defined when initialData is provided as a function which ALWAYS returns the data', () => { diff --git a/packages/vue-query/src/queryClient.ts b/packages/vue-query/src/queryClient.ts index 2568d83f26a..8c4004ee0c9 100644 --- a/packages/vue-query/src/queryClient.ts +++ b/packages/vue-query/src/queryClient.ts @@ -1,11 +1,17 @@ import { nextTick, ref } from 'vue-demi' import { QueryClient as QC } from '@tanstack/query-core' -import { cloneDeepUnref } from './utils' +import { cloneDeepUnref, toValueDeep } from './utils' import { QueryCache } from './queryCache' import { MutationCache } from './mutationCache' import type { UseQueryOptions } from './useQuery' import type { Ref } from 'vue-demi' -import type { MaybeRefDeep, NoUnknown, QueryClientConfig } from './types' +import type { + MaybeRefDeep, + NoUnknown, + QueryClientConfig, + QueryExecuteOptions, + InfiniteQueryExecuteOptions, +} from './types' import type { CancelOptions, DefaultError, @@ -66,6 +72,9 @@ export class QueryClient extends QC { return super.getQueryData(cloneDeepUnref(queryKey)) } + /** + * @deprecated Use queryClient.query({ ...options, staleTime: 'static' }) instead. This method will be removed in the next major version. + */ ensureQueryData< TQueryFnData, TError = DefaultError, @@ -74,6 +83,9 @@ export class QueryClient extends QC { >( options: EnsureQueryDataOptions, ): Promise + /** + * @deprecated Use queryClient.query({ ...options, staleTime: 'static' }) instead. This method will be removed in the next major version. + */ ensureQueryData< TQueryFnData, TError = DefaultError, @@ -261,6 +273,173 @@ export class QueryClient extends QC { ) } + query< + TQueryFnData, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = never, + >( + options: QueryExecuteOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey, + TPageParam + >, + ): Promise + query< + TQueryFnData, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = never, + >( + options: + | MaybeRefDeep< + QueryExecuteOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey, + TPageParam + > + > + | (() => MaybeRefDeep< + QueryExecuteOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey, + TPageParam + > + >), + ): Promise + query< + TQueryFnData, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = never, + >( + options: + | MaybeRefDeep< + QueryExecuteOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey, + TPageParam + > + > + | (() => MaybeRefDeep< + QueryExecuteOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey, + TPageParam + > + >), + ): Promise { + return super.query(cloneDeepUnref(options)) + } + + infiniteQuery< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, + >( + options: InfiniteQueryExecuteOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, + ): Promise< + Array extends Array> + ? InfiniteData + : TData + > + infiniteQuery< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, + >( + options: + | MaybeRefDeep< + InfiniteQueryExecuteOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + > + > + | (() => MaybeRefDeep< + InfiniteQueryExecuteOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + > + >), + ): Promise< + Array extends Array> + ? InfiniteData + : TData + > + infiniteQuery< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, + >( + options: + | MaybeRefDeep< + InfiniteQueryExecuteOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + > + > + | (() => MaybeRefDeep< + InfiniteQueryExecuteOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + > + >), + ): Promise< + Array extends Array> + ? InfiniteData + : TData + > { + return super.infiniteQuery(cloneDeepUnref(options)) + } + + /** + * @deprecated Use queryClient.query(options) instead. This method will be removed in the next major version. + */ fetchQuery< TQueryFnData, TError = DefaultError, @@ -276,6 +455,9 @@ export class QueryClient extends QC { TPageParam >, ): Promise + /** + * @deprecated Use queryClient.query(options) instead. This method will be removed in the next major version. + */ fetchQuery< TQueryFnData, TError = DefaultError, @@ -309,6 +491,9 @@ export class QueryClient extends QC { return super.fetchQuery(cloneDeepUnref(options)) } + /** + * @deprecated Use queryClient.query(options) instead. You can swallow errors with `.catch(noop)`. This method will be removed in the next major version. + */ prefetchQuery< TQueryFnData = unknown, TError = DefaultError, @@ -317,6 +502,9 @@ export class QueryClient extends QC { >( options: FetchQueryOptions, ): Promise + /** + * @deprecated Use queryClient.query(options) instead. You can swallow errors with `.catch(noop)`. This method will be removed in the next major version. + */ prefetchQuery< TQueryFnData = unknown, TError = DefaultError, @@ -340,6 +528,9 @@ export class QueryClient extends QC { return super.prefetchQuery(cloneDeepUnref(options)) } + /** + * @deprecated Use queryClient.infiniteQuery(options) instead. This method will be removed in the next major version. + */ fetchInfiniteQuery< TQueryFnData = unknown, TError = DefaultError, @@ -355,6 +546,9 @@ export class QueryClient extends QC { TPageParam >, ): Promise> + /** + * @deprecated Use queryClient.infiniteQuery(options) instead. This method will be removed in the next major version. + */ fetchInfiniteQuery< TQueryFnData, TError = DefaultError, @@ -392,6 +586,9 @@ export class QueryClient extends QC { return super.fetchInfiniteQuery(cloneDeepUnref(options)) } + /** + * @deprecated use void queryClient.infiniteQuery(options)instead. You can swallow errors with `.catch(noop)`. This method will be removed in the next major version. + */ prefetchInfiniteQuery< TQueryFnData, TError = DefaultError, @@ -407,6 +604,9 @@ export class QueryClient extends QC { TPageParam >, ): Promise + /** + * @deprecated use void queryClient.infiniteQuery(options)instead. You can swallow errors with `.catch(noop)`. This method will be removed in the next major version. + */ prefetchInfiniteQuery< TQueryFnData, TError = DefaultError, diff --git a/packages/vue-query/src/types.ts b/packages/vue-query/src/types.ts index 8ef5664b29f..c46ebd7ed4b 100644 --- a/packages/vue-query/src/types.ts +++ b/packages/vue-query/src/types.ts @@ -2,10 +2,17 @@ import type { DefaultError, DehydrateOptions, HydrateOptions, + // spellchecker is overzealous here + // eslint-disable-next-line cspell/spellchecker + InfiniteQueryExecuteOptions as IQCEOptions, + InfiniteData, MutationCache, MutationObserverOptions, OmitKeyof, + QueryExecuteOptions as QCEOptions, + QueryBooleanOption, QueryCache, + QueryKey, QueryObserverOptions, } from '@tanstack/query-core' import type { ComputedRef, Ref, UnwrapRef } from 'vue-demi' @@ -64,6 +71,92 @@ export type ShallowOption = { shallow?: boolean } +export type QueryExecuteOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> = { + [Property in keyof QCEOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey, + TPageParam + >]: Property extends 'enabled' + ? + | MaybeRefOrGetter + | (() => QueryBooleanOption< + TQueryFnData, + TError, + TQueryData, + DeepUnwrapRef + >) + | QCEOptions< + TQueryFnData, + TError, + TData, + TQueryData, + DeepUnwrapRef, + TPageParam + >[Property] + : QCEOptions< + TQueryFnData, + TError, + TData, + TQueryData, + DeepUnwrapRef, + TPageParam + >[Property] +} + +export type InfiniteQueryExecuteOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> = { + // spellchecker is overzealous here + // eslint-disable-next-line cspell/spellchecker + [Property in keyof IQCEOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >]: Property extends 'enabled' + ? + | MaybeRefOrGetter + | (() => QueryBooleanOption< + TQueryFnData, + TError, + TData, + DeepUnwrapRef + >) + // spellchecker is overzealous here + // eslint-disable-next-line cspell/spellchecker + | IQCEOptions< + TQueryFnData, + TError, + TData, + DeepUnwrapRef, + TPageParam + >[Property] + : // spellchecker is overzealous here + // eslint-disable-next-line cspell/spellchecker + IQCEOptions< + TQueryFnData, + TError, + TData, + DeepUnwrapRef, + TPageParam + >[Property] +} + export type MutationOptions< TData = unknown, TError = DefaultError,