diff --git a/.changeset/vue-query-skiptoken-getter-typecheck.md b/.changeset/vue-query-skiptoken-getter-typecheck.md new file mode 100644 index 00000000000..a4a5efa3f19 --- /dev/null +++ b/.changeset/vue-query-skiptoken-getter-typecheck.md @@ -0,0 +1,5 @@ +--- +'@tanstack/vue-query': minor +--- + +fix(vue-query): widen 'SkipToken' to 'symbol' and align 'queryOptions'/'infiniteQueryOptions' input diff --git a/packages/vue-query/src/__tests__/infiniteQueryOptions.test-d.ts b/packages/vue-query/src/__tests__/infiniteQueryOptions.test-d.ts index 09550310a42..f947890be99 100644 --- a/packages/vue-query/src/__tests__/infiniteQueryOptions.test-d.ts +++ b/packages/vue-query/src/__tests__/infiniteQueryOptions.test-d.ts @@ -1,20 +1,30 @@ import { assertType, describe, expectTypeOf, it } from 'vitest' -import { dataTagSymbol } from '@tanstack/query-core' -import { reactive } from 'vue-demi' +import { dataTagSymbol, skipToken } from '@tanstack/query-core' +import { computed, reactive, ref } from 'vue-demi' import { queryKey } from '@tanstack/query-test-utils' import { infiniteQueryOptions } from '../infiniteQueryOptions' import { QueryClient } from '../queryClient' import { useInfiniteQuery } from '../useInfiniteQuery' -import type { InfiniteData } from '@tanstack/query-core' +import type { InfiniteData, QueryKeyWithDataTag } from '@tanstack/query-core' +import type { InfiniteQueryOptions } from '../infiniteQueryOptions' // Regression test for exported infiniteQueryOptions inference under declaration emit. // TypeScript should be able to name the return type without expanding the // internal data tag symbols into the consumer's .d.ts output. -export const exportedInfiniteQueryOptions = infiniteQueryOptions({ - queryKey: ['invalid'], - getNextPageParam: () => 1, - initialPageParam: 1, -}) +export const exportedInfiniteQueryOptions: InfiniteQueryOptions< + unknown, + Error, + InfiniteData, + Array, + number +> & { + initialData?: undefined +} & QueryKeyWithDataTag, InfiniteData, Error> = + infiniteQueryOptions({ + queryKey: ['invalid'], + getNextPageParam: () => 1, + initialPageParam: 1, + }) describe('infiniteQueryOptions', () => { it('should not allow excess properties', () => { @@ -30,6 +40,66 @@ describe('infiniteQueryOptions', () => { }), ) }) + it('should allow a bare reactive getter for the whole queryKey array', () => { + const id = ref(1) + + const options = infiniteQueryOptions({ + queryKey: () => ['post', id.value] as const, + queryFn: () => Promise.resolve('data'), + getNextPageParam: () => 1, + initialPageParam: 1, + }) + + expectTypeOf(options.queryKey).not.toBeUndefined() + }) + it('should allow computed ref as enabled property', () => { + const enabled = computed(() => true) + + const options = infiniteQueryOptions({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + getNextPageParam: () => 1, + initialPageParam: 1, + enabled, + }) + + expectTypeOf(options.queryKey).not.toBeUndefined() + }) + it('should allow ref as enabled property', () => { + const enabled = ref(true) + + const options = infiniteQueryOptions({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + getNextPageParam: () => 1, + initialPageParam: 1, + enabled, + }) + + expectTypeOf(options.queryKey).not.toBeUndefined() + }) + it('should allow getter function as enabled property', () => { + const options = infiniteQueryOptions({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + getNextPageParam: () => 1, + initialPageParam: 1, + enabled: () => true, + }) + + expectTypeOf(options.queryKey).not.toBeUndefined() + }) + it('should allow a plain callback as enabled property', () => { + const options = infiniteQueryOptions({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + getNextPageParam: () => 1, + initialPageParam: 1, + enabled: (query) => query.state.data === undefined, + }) + + expectTypeOf(options.queryKey).not.toBeUndefined() + }) it('should infer types for callbacks', () => { const key = queryKey() infiniteQueryOptions({ @@ -162,4 +232,59 @@ describe('infiniteQueryOptions', () => { InfiniteData | undefined >() }) + + it('should allow a computed queryFn resolving to skipToken', () => { + const id = ref('1') + + const options = infiniteQueryOptions({ + queryKey: computed(() => ['foo', id.value]), + queryFn: computed(() => + id.value + ? ({ pageParam }: { pageParam: number }) => + Promise.resolve({ id: id.value, pageParam }) + : skipToken, + ), + getNextPageParam: () => 1, + initialPageParam: 1, + }) + + const { data } = reactive(useInfiniteQuery(options)) + + expectTypeOf(data).toEqualTypeOf< + InfiniteData<{ id: string | null; pageParam: number }> | undefined + >() + }) + + it('should reject a ref for an option other than enabled/queryKey/queryFn', () => { + // Unlike `useInfiniteQuery`, `infiniteQueryOptions` only tracks `enabled`/`queryKey`/`queryFn` reactively — + // every other option (`staleTime` here) stays a plain value. This is deliberate: the returned object is + // shared with plain APIs like `queryClient.infiniteQuery`, so a `ref` slipping into an arbitrary option + // would make the declared (plain) type lie about the actual (reactive) value. + assertType( + infiniteQueryOptions({ + queryKey: queryKey(), + queryFn: ({ pageParam }: { pageParam: number }) => + Promise.resolve(pageParam), + getNextPageParam: () => 1, + initialPageParam: 1, + // @ts-expect-error staleTime must be a plain value, not a ref + staleTime: ref(1000), + }), + ) + }) + + it('should reject the whole options object wrapped in a ref', () => { + assertType( + infiniteQueryOptions( + // @ts-expect-error infiniteQueryOptions only accepts a plain object, not a ref + ref({ + queryKey: queryKey(), + queryFn: ({ pageParam }: { pageParam: number }) => + Promise.resolve(pageParam), + getNextPageParam: () => 1, + initialPageParam: 1, + }), + ), + ) + }) }) diff --git a/packages/vue-query/src/__tests__/queryClient.test.ts b/packages/vue-query/src/__tests__/queryClient.test.ts index c126ebabfb8..fc4795b9502 100644 --- a/packages/vue-query/src/__tests__/queryClient.test.ts +++ b/packages/vue-query/src/__tests__/queryClient.test.ts @@ -470,8 +470,8 @@ describe('QueryCache', () => { const options = infiniteQueryOptions({ queryKey: queryKeyRef, - initialPageParam: ref(0), - getNextPageParam: ref(getNextPageParam), + initialPageParam: 0, + getNextPageParam, }) queryClient.infiniteQuery({ diff --git a/packages/vue-query/src/__tests__/queryOptions.test-d.ts b/packages/vue-query/src/__tests__/queryOptions.test-d.ts index 8b1ee84c1e6..8d38a6ab667 100644 --- a/packages/vue-query/src/__tests__/queryOptions.test-d.ts +++ b/packages/vue-query/src/__tests__/queryOptions.test-d.ts @@ -1,6 +1,6 @@ import { assertType, describe, expectTypeOf, it } from 'vitest' import { computed, reactive, ref } from 'vue-demi' -import { dataTagSymbol } from '@tanstack/query-core' +import { dataTagSymbol, skipToken } from '@tanstack/query-core' import { queryKey } from '@tanstack/query-test-utils' import { QueryClient } from '../queryClient' import { queryOptions } from '../queryOptions' @@ -362,4 +362,64 @@ describe('queryOptions', () => { expectTypeOf(options.queryKey).not.toBeUndefined() }) + + it('should narrow data to a defined type for a computed queryFn resolving to skipToken', () => { + const id = ref('1') + + const options = queryOptions({ + queryKey: computed(() => ['foo', id.value]), + queryFn: computed(() => + id.value ? () => Promise.resolve({ id: '1' }) : skipToken, + ), + }) + + const { data } = reactive(useQuery(options)) + + expectTypeOf(data).toEqualTypeOf<{ id: string } | undefined>() + }) + + it('should reject a ref for an option other than enabled/queryKey/queryFn', () => { + // Unlike `useQuery`, `queryOptions` only tracks `enabled`/`queryKey`/`queryFn` reactively — every other + // option (`staleTime` here) stays a plain value. This is deliberate: the returned object is shared with + // plain APIs like `queryClient.fetchQuery`, so a `ref` slipping into an arbitrary option would make the + // declared (plain) type lie about the actual (reactive) value. + assertType( + queryOptions({ + // The directive sits on `queryKey`, not `staleTime`: overload resolution fails on the whole + // object literal and TypeScript reports it at the first property. + // @ts-expect-error staleTime must be a plain value, not a ref + queryKey: queryKey(), + queryFn: () => Promise.resolve(5), + staleTime: ref(1000), + }), + ) + }) + + it('should reject the whole options object wrapped in a ref', () => { + assertType( + queryOptions( + // @ts-expect-error queryOptions only accepts a plain object or a getter for the whole object, not a ref + ref({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(5), + }), + ), + ) + }) + + it('should narrow data to a defined type for a conditional skipToken inside a whole-options getter', () => { + const id = ref('1') + + const options = queryOptions(() => { + const current = id.value + return { + queryKey: ['foo', current], + queryFn: current ? () => Promise.resolve({ id: current }) : skipToken, + } + }) + + const { data } = reactive(useQuery(options)) + + expectTypeOf(data).toEqualTypeOf<{ id: string } | undefined>() + }) }) diff --git a/packages/vue-query/src/__tests__/useInfiniteQuery.test-d.tsx b/packages/vue-query/src/__tests__/useInfiniteQuery.test-d.tsx index 273b4e02cff..56c1c0c1a27 100644 --- a/packages/vue-query/src/__tests__/useInfiniteQuery.test-d.tsx +++ b/packages/vue-query/src/__tests__/useInfiniteQuery.test-d.tsx @@ -1,5 +1,6 @@ import { assertType, describe, expectTypeOf, it } from 'vitest' import { computed, reactive, ref } from 'vue-demi' +import { skipToken } from '@tanstack/query-core' import { queryKey, sleep } from '@tanstack/query-test-utils' import { useInfiniteQuery } from '../useInfiniteQuery' import { infiniteQueryOptions } from '../infiniteQueryOptions' @@ -103,6 +104,31 @@ describe('Discriminated union return type', () => { } }) + it('should accept a computed queryFn resolving to skipToken', () => { + const key = queryKey() + const id = ref('1') + + // The resulting `data` type can't be asserted here: `vue-tsc`'s language-service plugin (unlike `tsc` or + // vitest's own typecheck) fails to resolve `TQueryFnData` through this inference path, leaking the + // unresolved type parameter into `query.data`'s type. Runtime skip/refetch behavior is covered in + // `useInfiniteQuery.test.ts`. + assertType( + reactive( + useInfiniteQuery({ + queryKey: key, + queryFn: computed(() => + id.value + ? ({ pageParam }: { pageParam: number }) => + sleep(0).then(() => 'data on page ' + pageParam) + : skipToken, + ), + getNextPageParam: () => undefined, + initialPageParam: 0, + }), + ), + ) + }) + it('should accept computed options using infiniteQueryOptions', () => { const key = queryKey() const options = computed(() => @@ -138,12 +164,10 @@ describe('Discriminated union return type', () => { }) describe('queryKey reactivity rules', () => { - it('should reject a bare reactive getter for the whole queryKey array', () => { + it('should accept a bare reactive getter for the whole queryKey array', () => { const id = ref(1) assertType( useInfiniteQuery({ - // @ts-expect-error when passed directly to useInfiniteQuery, queryKey cannot be a bare - // reactive getter for the whole array (queryOptions() allows this) queryKey: () => ['post', id.value], queryFn: () => sleep(0).then(() => 'Some data'), getNextPageParam: () => undefined, diff --git a/packages/vue-query/src/__tests__/useInfiniteQuery.test.ts b/packages/vue-query/src/__tests__/useInfiniteQuery.test.ts index a321b4c5c26..3b08e60aefd 100644 --- a/packages/vue-query/src/__tests__/useInfiniteQuery.test.ts +++ b/packages/vue-query/src/__tests__/useInfiniteQuery.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { ref } from 'vue-demi' +import { computed, ref } from 'vue-demi' +import { skipToken } from '@tanstack/query-core' import { queryKey, sleep } from '@tanstack/query-test-utils' import { useInfiniteQuery } from '../useInfiniteQuery' import { infiniteQueryOptions } from '../infiniteQueryOptions' @@ -129,4 +130,60 @@ describe('useInfiniteQuery', () => { expect(hasNextPage.value).toBe(false) expect(isFetching.value).toBe(false) }) + + it('should skip the query while a computed queryFn resolves to skipToken, and run it once defined', async () => { + const key = queryKey() + const id = ref(null) + const fetchFn = vi.fn(({ pageParam }: { pageParam: number }) => + sleep(10).then(() => 'data on page ' + pageParam), + ) + + const { data, status } = useInfiniteQuery({ + queryKey: key, + queryFn: computed(() => (id.value ? fetchFn : skipToken)), + initialPageParam: 0, + getNextPageParam: () => 12, + }) + + await vi.advanceTimersByTimeAsync(10) + + expect(fetchFn).not.toHaveBeenCalled() + expect(status.value).toStrictEqual('pending') + + id.value = '1' + + await vi.advanceTimersByTimeAsync(10) + + expect(fetchFn).toHaveBeenCalledTimes(1) + expect(status.value).toStrictEqual('success') + expect(data.value).toStrictEqual({ + pageParams: [0], + pages: ['data on page 0'], + }) + }) + + describe('queryKey reactivity rules', () => { + it('should refetch when a bare reactive getter for the whole queryKey array changes', async () => { + const key = queryKey() + const id = ref(1) + const fetchFn = vi.fn(() => sleep(10).then(() => 'Some data')) + + useInfiniteQuery({ + queryKey: () => [...key, id.value], + queryFn: fetchFn, + initialPageParam: 0, + getNextPageParam: () => undefined, + }) + + await vi.advanceTimersByTimeAsync(10) + + expect(fetchFn).toHaveBeenCalledTimes(1) + + id.value = 2 + + await vi.advanceTimersByTimeAsync(10) + + expect(fetchFn).toHaveBeenCalledTimes(2) + }) + }) }) diff --git a/packages/vue-query/src/__tests__/useQueries.test-d.ts b/packages/vue-query/src/__tests__/useQueries.test-d.ts index 93566cb36ea..ab280b434c2 100644 --- a/packages/vue-query/src/__tests__/useQueries.test-d.ts +++ b/packages/vue-query/src/__tests__/useQueries.test-d.ts @@ -1,10 +1,11 @@ import { describe, expectTypeOf, it } from 'vitest' import { reactive } from 'vue' +import { computed, ref } from 'vue-demi' import { queryKey } from '@tanstack/query-test-utils' import { skipToken, useQueries } from '..' import { queryOptions } from '../queryOptions' import type { OmitKeyof, QueryObserverResult } from '..' -import type { UseQueryOptions } from '../useQuery' +import type { UseQueryOptions } from '../queryOptions' describe('UseQueries config object overload', () => { it('TData should always be defined when initialData is provided as an object', () => { @@ -127,6 +128,37 @@ describe('UseQueries config object overload', () => { expectTypeOf(firstResult.data).toEqualTypeOf() }) + it('TData should have correct type when queryFn is a computed resolving to skipToken', () => { + const key = queryKey() + const id = ref('1') + const { value: queriesState } = useQueries({ + queries: [ + { + queryKey: key, + queryFn: computed(() => + id.value ? () => Promise.resolve(5) : skipToken, + ), + }, + ], + }) + + expectTypeOf(queriesState[0].data).toEqualTypeOf() + }) + + it('should allow a bare reactive getter for the whole queryKey array', () => { + const id = ref(1) + const { value: queriesState } = useQueries({ + queries: [ + { + queryKey: () => ['post', id.value], + queryFn: () => Promise.resolve(5), + }, + ], + }) + + expectTypeOf(queriesState[0].data).toEqualTypeOf() + }) + describe('custom hook', () => { it('should allow custom hooks using UseQueryOptions', () => { const useCustomQueries = ( diff --git a/packages/vue-query/src/__tests__/useQueries.test.ts b/packages/vue-query/src/__tests__/useQueries.test.ts index e0ba22953f1..f40b60fa6b8 100644 --- a/packages/vue-query/src/__tests__/useQueries.test.ts +++ b/packages/vue-query/src/__tests__/useQueries.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { onScopeDispose, ref } from 'vue-demi' +import { computed, onScopeDispose, ref } from 'vue-demi' +import { skipToken } from '@tanstack/query-core' import { queryKey, sleep } from '@tanstack/query-test-utils' import { useQueries } from '../useQueries' import { useQueryClient } from '../useQueryClient' @@ -505,4 +506,58 @@ describe('useQueries', () => { expect(fetchFn).toHaveBeenCalledTimes(6) }) + + it('should skip a query while a computed queryFn resolves to skipToken, and run it once defined', async () => { + const key = queryKey() + const id = ref(null) + const fetchFn = vi.fn(() => sleep(10).then(() => 'Some data')) + + const queriesState = useQueries({ + queries: [ + { + queryKey: key, + queryFn: computed(() => (id.value ? fetchFn : skipToken)), + }, + ], + }) + + await vi.advanceTimersByTimeAsync(10) + + expect(fetchFn).not.toHaveBeenCalled() + expect(queriesState.value).toMatchObject([{ status: 'pending' }]) + + id.value = '1' + + await vi.advanceTimersByTimeAsync(10) + + expect(fetchFn).toHaveBeenCalledTimes(1) + expect(queriesState.value).toMatchObject([ + { status: 'success', data: 'Some data' }, + ]) + }) + + it('should refetch when a bare reactive getter for the whole queryKey array changes', async () => { + const key = queryKey() + const id = ref(1) + const fetchFn = vi.fn(() => sleep(10).then(() => 'Some data')) + + useQueries({ + queries: [ + { + queryKey: () => [...key, id.value], + queryFn: fetchFn, + }, + ], + }) + + await vi.advanceTimersByTimeAsync(10) + + expect(fetchFn).toHaveBeenCalledTimes(1) + + id.value = 2 + + await vi.advanceTimersByTimeAsync(10) + + expect(fetchFn).toHaveBeenCalledTimes(2) + }) }) diff --git a/packages/vue-query/src/__tests__/useQuery.test-d.ts b/packages/vue-query/src/__tests__/useQuery.test-d.ts index a9b0125ab08..14b11a5b5b8 100644 --- a/packages/vue-query/src/__tests__/useQuery.test-d.ts +++ b/packages/vue-query/src/__tests__/useQuery.test-d.ts @@ -1,7 +1,7 @@ import { assertType, describe, expectTypeOf, it } from 'vitest' import { computed, reactive, ref } from 'vue-demi' import { queryKey, sleep } from '@tanstack/query-test-utils' -import { queryOptions, useQuery } from '..' +import { queryOptions, skipToken, useQuery } from '..' import type { Ref } from 'vue-demi' import type { OmitKeyof, UseQueryOptions, UseQueryReturnType } from '..' @@ -361,12 +361,10 @@ describe('useQuery', () => { }) describe('queryKey reactivity rules', () => { - it('should reject a bare reactive getter for the whole queryKey array', () => { + it('should accept a bare reactive getter for the whole queryKey array', () => { const id = ref(1) assertType( useQuery({ - // @ts-expect-error when passed directly to useQuery, queryKey cannot be a bare - // reactive getter for the whole array (queryOptions() allows this) queryKey: () => ['post', id.value], queryFn: () => sleep(0).then(() => 'Some data'), }), @@ -385,4 +383,56 @@ describe('useQuery', () => { expectTypeOf(data.value).toEqualTypeOf() }) }) + + describe('skipToken', () => { + it('should accept a computed queryFn resolving to skipToken', () => { + const postId = ref() + + // `data`'s resulting type can't be asserted here: `vue-tsc`'s language-service plugin (unlike `tsc` or + // vitest's own typecheck) fails to resolve `TQueryFnData` through this inference path, leaking the + // unresolved type parameter into `data`'s type. Runtime skip/refetch behavior is covered in + // `useQuery.test.ts`. + assertType( + useQuery({ + queryKey: ['post', postId], + queryFn: computed(() => + postId.value != null + ? () => sleep(0).then(() => `post ${postId.value}`) + : skipToken, + ), + }), + ) + }) + + it('should narrow data to string | undefined for a conditional skipToken inside a whole-options getter', () => { + const postId = ref() + + const { data } = useQuery(() => { + const id = postId.value + return { + queryKey: ['post', id], + queryFn: + id != null ? () => sleep(0).then(() => `post ${id}`) : skipToken, + } + }) + + expectTypeOf(data.value).toEqualTypeOf() + }) + + it('known tradeoff: widening SkipToken to a plain symbol also accepts unrelated symbol values', () => { + // `queryFn`'s type accepts any `symbol`, not just `SkipToken`, because narrowing to the `unique + // symbol` that `SkipToken` actually is breaks type inference for the ternary above — same tradeoff + // already accepted in `useQueries.ts`'s `SkipTokenForUseQueries`. This isn't type-safe: only the + // exact `skipToken` identity disables a query, so an unrelated symbol stays enabled and throws once + // the fetch path tries to invoke it as a function. + const unrelatedSymbol: unique symbol = Symbol('unrelated') + + const { data } = useQuery({ + queryKey: ['post'], + queryFn: unrelatedSymbol, + }) + + expectTypeOf(data.value).toEqualTypeOf() + }) + }) }) diff --git a/packages/vue-query/src/__tests__/useQuery.test.ts b/packages/vue-query/src/__tests__/useQuery.test.ts index a648d3b001e..9d21f3aac49 100644 --- a/packages/vue-query/src/__tests__/useQuery.test.ts +++ b/packages/vue-query/src/__tests__/useQuery.test.ts @@ -6,7 +6,11 @@ import { reactive, ref, } from 'vue-demi' -import { QueryObserver, experimental_streamedQuery } from '@tanstack/query-core' +import { + QueryObserver, + experimental_streamedQuery, + skipToken, +} from '@tanstack/query-core' import { queryKey, sleep } from '@tanstack/query-test-utils' import { keepPreviousData } from '..' import { useQuery } from '../useQuery' @@ -778,4 +782,81 @@ describe('useQuery', () => { expect(result.data).toStrictEqual(['chunk1']) }) }) + + describe('skipToken', () => { + it('should skip the query while a computed queryFn resolves to skipToken, and run it once defined', async () => { + const key = queryKey() + const id = ref(null) + const fetchFn = vi.fn(() => sleep(10).then(() => 'Some data')) + + const query = useQuery({ + queryKey: key, + queryFn: computed(() => (id.value ? fetchFn : skipToken)), + }) + + await vi.advanceTimersByTimeAsync(10) + + expect(fetchFn).not.toHaveBeenCalled() + expect(query).toMatchObject({ status: { value: 'pending' } }) + + id.value = '1' + + await vi.advanceTimersByTimeAsync(10) + + expect(fetchFn).toHaveBeenCalledTimes(1) + expect(query).toMatchObject({ + status: { value: 'success' }, + data: { value: 'Some data' }, + }) + }) + + it('should skip the query while a whole-options getter resolves queryFn to skipToken, and run it once defined', async () => { + const key = queryKey() + const id = ref(null) + const fetchFn = vi.fn(() => sleep(10).then(() => 'Some data')) + + const query = useQuery(() => ({ + queryKey: key, + queryFn: id.value ? fetchFn : skipToken, + })) + + await vi.advanceTimersByTimeAsync(10) + + expect(fetchFn).not.toHaveBeenCalled() + expect(query).toMatchObject({ status: { value: 'pending' } }) + + id.value = '1' + + await vi.advanceTimersByTimeAsync(10) + + expect(fetchFn).toHaveBeenCalledTimes(1) + expect(query).toMatchObject({ + status: { value: 'success' }, + data: { value: 'Some data' }, + }) + }) + }) + + describe('queryKey reactivity rules', () => { + it('should refetch when a bare reactive getter for the whole queryKey array changes', async () => { + const key = queryKey() + const id = ref(1) + const fetchFn = vi.fn(() => sleep(10).then(() => 'Some data')) + + useQuery({ + queryKey: () => [...key, id.value], + queryFn: fetchFn, + }) + + await vi.advanceTimersByTimeAsync(10) + + expect(fetchFn).toHaveBeenCalledTimes(1) + + id.value = 2 + + await vi.advanceTimersByTimeAsync(10) + + expect(fetchFn).toHaveBeenCalledTimes(2) + }) + }) }) diff --git a/packages/vue-query/src/index.ts b/packages/vue-query/src/index.ts index c58b89cf2a4..9c54ae4ce4d 100644 --- a/packages/vue-query/src/index.ts +++ b/packages/vue-query/src/index.ts @@ -8,11 +8,15 @@ export { QueryCache } from './queryCache' export { queryOptions } from './queryOptions' export type { QueryOptions, + UseQueryOptions, + UndefinedInitialQueryOptions, + DefinedInitialQueryOptions, UndefinedInitialQueryOptionsWithDataTag, DefinedInitialQueryOptionsWithDataTag, } from './queryOptions' export { infiniteQueryOptions } from './infiniteQueryOptions' export type { + InfiniteQueryOptions, DefinedInitialDataInfiniteOptions, UndefinedInitialDataInfiniteOptions, } from './infiniteQueryOptions' @@ -30,13 +34,7 @@ export { VUE_QUERY_CLIENT } from './utils' export type { UsePrefetchQueryOptions } from './usePrefetchQuery' export type { UsePrefetchInfiniteQueryOptions } from './usePrefetchInfiniteQuery' -export type { - UseQueryOptions, - UseQueryReturnType, - UseQueryDefinedReturnType, - UndefinedInitialQueryOptions, - DefinedInitialQueryOptions, -} from './useQuery' +export type { UseQueryReturnType, UseQueryDefinedReturnType } from './useQuery' export type { UseInfiniteQueryOptions, UseInfiniteQueryReturnType, diff --git a/packages/vue-query/src/infiniteQueryOptions.ts b/packages/vue-query/src/infiniteQueryOptions.ts index 518305e43e8..281c58f2367 100644 --- a/packages/vue-query/src/infiniteQueryOptions.ts +++ b/packages/vue-query/src/infiniteQueryOptions.ts @@ -1,11 +1,72 @@ import type { DefaultError, InfiniteData, + InfiniteQueryObserverOptions, NonUndefinedGuard, + OmitKeyof, QueryKey, QueryKeyWithDataTag, } from '@tanstack/query-core' -import type { UseInfiniteQueryOptions } from './useInfiniteQuery' +import type { + DeepUnwrapRef, + MaybeRefDeep, + MaybeRefOrGetter, + ShallowOption, +} from './types' + +// Widen `SkipToken`'s `unique symbol` to `symbol` so it survives a `queryFn: cond ? fn : skipToken` ternary +// inside a whole-options getter or a `computed` — see `SkipTokenForUseQuery` in `queryOptions.ts`. Only the +// `infiniteQueryOptions()` input widens: `InfiniteQueryOptions` keeps `unique symbol` so the object +// `infiniteQueryOptions()` hands back still satisfies `QueryClient` methods like `queryClient.infiniteQuery`. +type SkipTokenForInfiniteQuery = symbol + +/** + * The plain, unwrapped options that `infiniteQueryOptions` hands back, and what `useInfiniteQuery` and the + * `queryClient` methods see once `ref`s have been resolved. `enabled` and `queryKey` track reactive + * dependencies automatically as a `ref`, a plain value, or a reactive getter (`() => ...`). Every other + * option is a plain value here; to close over reactive state in any of them, use {@link UseInfiniteQueryOptions} + * directly, or pass a getter for the whole options object instead (`useInfiniteQuery(() => ({ ... }))`). + * + * @template TQueryFnData - The type of a single page, as your `queryFn` resolves it. + * @template TError - The type of errors your `queryFn` may throw. + * @template TData - The type `data` ends up as after `select` runs — defaults to `InfiniteData`, + * the shape of all fetched pages plus their page params. + * @template TQueryKey - The type of your `queryKey`. + * @template TPageParam - The type of the parameter passed to `queryFn` to fetch a given page. + */ +export type InfiniteQueryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> = { + [Property in keyof InfiniteQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >]: Property extends 'enabled' + ? MaybeRefOrGetter< + InfiniteQueryObserverOptions< + TQueryFnData, + TError, + TData, + DeepUnwrapRef, + TPageParam + >['enabled'] + > + : Property extends 'queryKey' + ? MaybeRefOrGetter + : InfiniteQueryObserverOptions< + TQueryFnData, + TError, + TData, + DeepUnwrapRef, + TPageParam + >[Property] +} & ShallowOption /** * The options accepted by the `infiniteQueryOptions` overload selected when no `initialData` is set — `data` @@ -24,13 +85,20 @@ export type UndefinedInitialDataInfiniteOptions< TData = InfiniteData, TQueryKey extends QueryKey = QueryKey, TPageParam = unknown, -> = UseInfiniteQueryOptions< - TQueryFnData, - TError, - TData, - TQueryKey, - TPageParam +> = OmitKeyof< + InfiniteQueryOptions, + 'queryFn' > & { + queryFn?: MaybeRefDeep< + | InfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >['queryFn'] + | SkipTokenForInfiniteQuery + > initialData?: undefined } @@ -51,13 +119,20 @@ export type DefinedInitialDataInfiniteOptions< TData = InfiniteData, TQueryKey extends QueryKey = QueryKey, TPageParam = unknown, -> = UseInfiniteQueryOptions< - TQueryFnData, - TError, - TData, - TQueryKey, - TPageParam +> = OmitKeyof< + InfiniteQueryOptions, + 'queryFn' > & { + queryFn?: MaybeRefDeep< + | InfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >['queryFn'] + | SkipTokenForInfiniteQuery + > /** * If set, this value will be used as the initial data for the query cache (as long as the query hasn't been * created or cached yet). If set to a function, the function will be called **once** during the shared/root @@ -110,14 +185,9 @@ export function infiniteQueryOptions< TQueryKey, TPageParam >, -): UndefinedInitialDataInfiniteOptions< - TQueryFnData, - TError, - TData, - TQueryKey, - TPageParam -> & - QueryKeyWithDataTag, TError> +): InfiniteQueryOptions & { + initialData?: undefined +} & QueryKeyWithDataTag, TError> /** * You can generally pass everything to `infiniteQueryOptions` that you can also pass to `useInfiniteQuery`. @@ -164,14 +234,11 @@ export function infiniteQueryOptions< TQueryKey, TPageParam >, -): DefinedInitialDataInfiniteOptions< - TQueryFnData, - TError, - TData, - TQueryKey, - TPageParam -> & - QueryKeyWithDataTag, TError> +): InfiniteQueryOptions & { + initialData: + | NonUndefinedGuard> + | (() => NonUndefinedGuard>) +} & QueryKeyWithDataTag, TError> export function infiniteQueryOptions(options: unknown) { return options diff --git a/packages/vue-query/src/queryClient.ts b/packages/vue-query/src/queryClient.ts index 8cedfe2756d..0e39f93579b 100644 --- a/packages/vue-query/src/queryClient.ts +++ b/packages/vue-query/src/queryClient.ts @@ -3,7 +3,7 @@ import { QueryClient as QC } from '@tanstack/query-core' import { cloneDeepUnref } from './utils' import { QueryCache } from './queryCache' import { MutationCache } from './mutationCache' -import type { UseQueryOptions } from './useQuery' +import type { UseQueryOptions } from './queryOptions' import type { Ref } from 'vue-demi' import type { MaybeRefDeep, NoUnknown, QueryClientConfig } from './types' import type { diff --git a/packages/vue-query/src/queryOptions.ts b/packages/vue-query/src/queryOptions.ts index 1b5093298fe..5c0fc3204a5 100644 --- a/packages/vue-query/src/queryOptions.ts +++ b/packages/vue-query/src/queryOptions.ts @@ -1,29 +1,34 @@ -import type { DeepUnwrapRef, MaybeRefOrGetter, ShallowOption } from './types' +import type { + DeepUnwrapRef, + MaybeRef, + MaybeRefDeep, + MaybeRefOrGetter, + ShallowOption, +} from './types' import type { DefaultError, InitialDataFunction, NonUndefinedGuard, + OmitKeyof, QueryBooleanOption, QueryKey, QueryKeyWithDataTag, QueryObserverOptions, } from '@tanstack/query-core' +// Widen `SkipToken`'s `unique symbol` to `symbol` so it survives a `queryFn: cond ? fn : skipToken` +// ternary inside a whole-options getter or a `computed` — see `SkipTokenForUseQueries` in `useQueries.ts`. +// Only `UseQueryOptions` (the *input* type) widens: `QueryOptions` keeps `unique symbol` so the object +// `queryOptions()` hands back still satisfies `QueryClient` methods like `fetchQuery`/`invalidateQueries`. +type SkipTokenForUseQuery = symbol + /** - * The options accepted by `queryOptions`, `useQuery`, and the other query hooks. `enabled` tracks reactive - * dependencies automatically as a `ref`, a plain value, or a reactive getter (`() => ...`). `queryKey` reacts - * through a `ref` for the array itself, or `ref`s and reactive getters as individual entries — the array - * itself can't be a bare getter. Other options passed this way are read once and are not reactive. - * - * If you instead pass a getter for the whole options object (`useQuery(() => ({ ... }))`), every option - * inside it — including `staleTime`, `retry`, and `select` — is re-evaluated whenever the getter's own - * reactive dependencies change, since the entire object is recomputed. - * - * `select` only re-runs when `data` changes, or when the `select` function's own reference changes. Since a - * Vue `setup()` function runs only once per component instance, an inline `select` function passed directly - * to `queryOptions`/`useQuery` already has a stable reference across reactive updates. An inline `select` - * created inside a whole-options getter is recreated — and so can change reference — every time that getter - * re-evaluates. + * The plain, unwrapped options that `queryOptions` hands back, and what `useQuery`, `useQueries`, and the + * `queryClient` methods see once `ref`s have been resolved. `enabled` and `queryKey` track reactive + * dependencies automatically as a `ref`, a plain value, or a reactive getter (`() => ...`). Every other + * option — including `queryFn` — is a plain value here; to pass `queryFn` as a `ref`/`computed`, or to close + * over reactive state in any other option, use {@link UseQueryOptions} directly, or pass a getter for the + * whole options object instead (`useQuery(() => ({ ... }))`). * * @template TQueryFnData - The type your `queryFn` resolves to. * @template TError - The type of errors your `queryFn` may throw. @@ -65,6 +70,76 @@ export type QueryOptions< >[Property] } & ShallowOption +/** + * The options accepted by `queryOptions`, `useQuery`, and the other query hooks. `enabled` tracks reactive + * dependencies automatically as a `ref`, a plain value, or a reactive getter (`() => ...`). `queryKey` reacts + * through a `ref` or a reactive getter for the array itself, or `ref`s and reactive getters as individual + * entries. `queryFn` reacts through a `ref` or a `computed`, but never a bare getter, since a function there + * is the query function itself. Other options are read once when passed as a plain value, and stay reactive + * when passed as a `ref` or a `computed`. + * + * If you instead pass a getter for the whole options object (`useQuery(() => ({ ... }))`), every option + * inside it — including `staleTime`, `retry`, and `select` — is re-evaluated whenever the getter's own + * reactive dependencies change, since the entire object is recomputed. + * + * `select` only re-runs when `data` changes, or when the `select` function's own reference changes. Since a + * Vue `setup()` function runs only once per component instance, an inline `select` function passed directly + * to `queryOptions`/`useQuery` already has a stable reference across reactive updates. An inline `select` + * created inside a whole-options getter is recreated — and so can change reference — every time that getter + * re-evaluates. + * + * @template TQueryFnData - The type your `queryFn` resolves to. + * @template TError - The type of errors your `queryFn` may throw. + * @template TData - The type `data` ends up as after `select` runs. + * @template TQueryData - The type of data stored in the cache, before `select` runs. Defaults to + * `TQueryFnData` and can be configured independently of it. + * @template TQueryKey - The type of your `queryKey`. + */ +export type UseQueryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = MaybeRef< + { + [Property in keyof QueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + >]: Property extends 'enabled' | 'queryKey' + ? QueryOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + >[Property] + : Property extends 'queryFn' + ? MaybeRefDeep< + | QueryOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + >[Property] + | SkipTokenForUseQuery + > + : MaybeRefDeep< + QueryOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + >[Property] + > + } & ShallowOption +> + /** * The options accepted by the `queryOptions` overload selected when no `initialData` is set — `data` may be * `undefined` while the query is `pending`. @@ -79,7 +154,20 @@ export type UndefinedInitialQueryOptions< TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, -> = QueryOptions & { +> = OmitKeyof< + QueryOptions, + 'queryFn' +> & { + queryFn?: MaybeRefDeep< + | QueryOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey + >['queryFn'] + | SkipTokenForUseQuery + > /** * If set, this value will be used as the initial data for the query cache (as long as the query hasn't been * created or cached yet). If set to a function, the function will be called **once** during the shared/root @@ -107,7 +195,20 @@ export type DefinedInitialQueryOptions< TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, -> = QueryOptions & { +> = OmitKeyof< + QueryOptions, + 'queryFn' +> & { + queryFn?: MaybeRefDeep< + | QueryOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey + >['queryFn'] + | SkipTokenForUseQuery + > /** * If set, this value will be used as the initial data for the query cache (as long as the query hasn't been * created or cached yet). If set to a function, the function will be called **once** during the shared/root @@ -125,16 +226,23 @@ export type UndefinedInitialQueryOptionsWithDataTag< TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, -> = UndefinedInitialQueryOptions & - QueryKeyWithDataTag +> = QueryOptions & { + initialData?: + | undefined + | InitialDataFunction> + | NonUndefinedGuard +} & QueryKeyWithDataTag export type DefinedInitialQueryOptionsWithDataTag< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, -> = DefinedInitialQueryOptions & - QueryKeyWithDataTag +> = QueryOptions & { + initialData: + | NonUndefinedGuard + | (() => NonUndefinedGuard) +} & QueryKeyWithDataTag /** * You can generally pass everything to `queryOptions` that you can also pass to `useQuery`. These options can @@ -294,9 +402,9 @@ export function queryOptions< * ``` * * @example - * A parameterized factory that disables the query, type safe, until `postId` is set. This requires a - * whole-options getter: `queryFn` is a single value, not `queryKey`/`enabled`, so it isn't itself reactive — - * the getter is what re-evaluates it on every change to `postId`: + * A parameterized factory that disables the query, type safe, until `postId` is set. The whole-options getter + * re-evaluates `queryFn` on every change to `postId`. `queryFn` can also be a `computed`, but never a bare + * getter, since a function there is the query function itself: * ```vue *