diff --git a/docs/src/pages/reference/QueryClient.md b/docs/src/pages/reference/QueryClient.md index 69e1448327..ff73335a0a 100644 --- a/docs/src/pages/reference/QueryClient.md +++ b/docs/src/pages/reference/QueryClient.md @@ -224,6 +224,8 @@ queryClient.setQueryData(queryKey, updater) setQueryData(queryKey, newData) ``` +If the value is `undefined`, the query data is not updated. + **Using an updater function** For convenience in syntax, you can also pass an updater function which receives the current data value and returns the new one: @@ -232,6 +234,8 @@ For convenience in syntax, you can also pass an updater function which receives setQueryData(queryKey, oldData => newData) ``` +If the updater function returns `undefined`, the query data will not be updated. + ## `queryClient.getQueryState` `getQueryState` is a synchronous function that can be used to get an existing query's state. If the query does not exist, `undefined` will be returned. diff --git a/docs/src/pages/reference/useQuery.md b/docs/src/pages/reference/useQuery.md index 4f628bebbc..e826bed046 100644 --- a/docs/src/pages/reference/useQuery.md +++ b/docs/src/pages/reference/useQuery.md @@ -68,7 +68,7 @@ const result = useQuery({ **Options** -- `queryKey: unknown[]` +- `queryKey: unknown[]` - **Required** - The query key to use for this query. - The query key will be hashed into a stable hash. See [Query Keys](../guides/query-keys) for more information. @@ -77,7 +77,7 @@ const result = useQuery({ - **Required, but only if no default query function has been defined** See [Default Query Function](../guides/default-query-function) for more information. - The function that the query will use to request data. - Receives a [QueryFunctionContext](../guides/query-functions#queryfunctioncontext) - - Must return a promise that will either resolve data or throw an error. + - Must return a promise that will either resolve data or throw an error. The data cannot be `undefined`. - `enabled: boolean` - Set this to `false` to disable this query from automatically running. - Can be used for [Dependent Queries](../guides/dependent-queries). diff --git a/src/core/query.ts b/src/core/query.ts index 06935088dc..be087dbbea 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -1,7 +1,5 @@ import { getAbortController, - Updater, - functionalUpdate, noop, replaceEqualDeep, timeUntilStale, @@ -195,14 +193,11 @@ export class Query< } setData( - updater: Updater<TData | undefined, TData>, + data: TData, options?: SetDataOptions & { notifySuccess: boolean } ): TData { const prevData = this.state.data - // Get the new data - let data = functionalUpdate(updater, prevData) - // Use prev data if an isDataEqual function is defined and returns `true` if (this.options.isDataEqual?.(prevData, data)) { data = prevData as TData @@ -438,11 +433,41 @@ export class Query< this.dispatch({ type: 'fetch', meta: context.fetchOptions?.meta }) } + const onError = (error: TError | { silent?: boolean }) => { + // Optimistically update state if needed + if (!(isCancelledError(error) && error.silent)) { + this.dispatch({ + type: 'error', + error: error as TError, + }) + } + + if (!isCancelledError(error)) { + // Notify cache callback + this.cache.config.onError?.(error, this as Query<any, any, any, any>) + + if (process.env.NODE_ENV !== 'production') { + getLogger().error(error) + } + } + + if (!this.isFetchingOptimistic) { + // Schedule query gc after fetching + this.scheduleGc() + } + this.isFetchingOptimistic = false + } + // Try to fetch the data this.retryer = createRetryer({ fn: context.fetchFn as () => TData, abort: abortController?.abort?.bind(abortController), onSuccess: data => { + if (typeof data === 'undefined') { + onError(new Error('Query data cannot be undefined') as any) + return + } + this.setData(data as TData) // Notify cache callback @@ -454,30 +479,7 @@ export class Query< } this.isFetchingOptimistic = false }, - onError: (error: TError | { silent?: boolean }) => { - // Optimistically update state if needed - if (!(isCancelledError(error) && error.silent)) { - this.dispatch({ - type: 'error', - error: error as TError, - }) - } - - if (!isCancelledError(error)) { - // Notify cache callback - this.cache.config.onError?.(error, this as Query<any, any, any, any>) - - if (process.env.NODE_ENV !== 'production') { - getLogger().error(error) - } - } - - if (!this.isFetchingOptimistic) { - // Schedule query gc after fetching - this.scheduleGc() - } - this.isFetchingOptimistic = false - }, + onError, onFail: () => { this.dispatch({ type: 'failed' }) }, diff --git a/src/core/queryClient.ts b/src/core/queryClient.ts index d09461ef83..589b723504 100644 --- a/src/core/queryClient.ts +++ b/src/core/queryClient.ts @@ -8,6 +8,7 @@ import { partialMatchKey, hashQueryKeyByOptions, MutationFilters, + functionalUpdate, } from './utils' import type { QueryClientConfig, @@ -125,14 +126,22 @@ export class QueryClient { setQueryData<TData>( queryKey: QueryKey, - updater: Updater<TData | undefined, TData>, + updater: Updater<TData | undefined, TData> | undefined, options?: SetDataOptions - ): TData { + ): TData | undefined { + const query = this.queryCache.find<TData>(queryKey) + const prevData = query?.state.data + const data = functionalUpdate(updater, prevData) + + if (typeof data === 'undefined') { + return undefined + } + const parsedOptions = parseQueryArgs(queryKey) const defaultedOptions = this.defaultQueryOptions(parsedOptions) return this.queryCache .build(this, defaultedOptions) - .setData(updater, { ...options, notifySuccess: false }) + .setData(data, { ...options, notifySuccess: false }) } setQueriesData<TData>( @@ -151,7 +160,7 @@ export class QueryClient { queryKeyOrFilters: QueryKey | QueryFilters, updater: Updater<TData | undefined, TData>, options?: SetDataOptions - ): [QueryKey, TData][] { + ): [QueryKey, TData | undefined][] { return notifyManager.batch(() => this.getQueryCache() .findAll(queryKeyOrFilters) diff --git a/src/core/tests/query.test.tsx b/src/core/tests/query.test.tsx index cc120b933d..b5aefa4b14 100644 --- a/src/core/tests/query.test.tsx +++ b/src/core/tests/query.test.tsx @@ -12,6 +12,7 @@ import { isError, onlineManager, QueryFunctionContext, + QueryObserverResult, } from '../..' import { waitFor } from '@testing-library/react' @@ -787,6 +788,7 @@ describe('query', () => { let signalTest: any await queryClient.prefetchQuery(key, ({ signal }) => { signalTest = signal + return 'data' }) expect(signalTest).toBeUndefined() @@ -814,6 +816,31 @@ describe('query', () => { consoleMock.mockRestore() }) + test('fetch should dispatch an error if the queryFn returns undefined', async () => { + const key = queryKey() + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: (() => undefined) as any, + retry: false, + }) + + let observerResult: QueryObserverResult<unknown, unknown> | undefined + + const unsubscribe = observer.subscribe(result => { + observerResult = result + }) + + await sleep(10) + + expect(observerResult).toMatchObject({ + isError: true, + error: new Error('Query data cannot be undefined'), + }) + + unsubscribe() + }) + test('fetch should dispatch fetch if is fetching and current promise is undefined', async () => { const key = queryKey() diff --git a/src/core/tests/queryClient.test.tsx b/src/core/tests/queryClient.test.tsx index c32cf97cc0..0d60ff4206 100644 --- a/src/core/tests/queryClient.test.tsx +++ b/src/core/tests/queryClient.test.tsx @@ -53,7 +53,7 @@ describe('queryClient', () => { }, }) - const fetchData = () => Promise.resolve(undefined) + const fetchData = () => Promise.resolve('data') await testClient.prefetchQuery(key, fetchData) const newQuery = testClient.getQueryCache().find(key) expect(newQuery?.options.cacheTime).toBe(Infinity) @@ -301,6 +301,34 @@ describe('queryClient', () => { expect(queryClient.getQueryData(key)).toBe('qux') }) + test('should not create a new query if query was not found and data is undefined', () => { + const key = queryKey() + expect(queryClient.getQueryCache().find(key)).toBe(undefined) + queryClient.setQueryData(key, undefined) + expect(queryClient.getQueryCache().find(key)).toBe(undefined) + }) + + test('should not create a new query if query was not found and updater returns undefined', () => { + const key = queryKey() + expect(queryClient.getQueryCache().find(key)).toBe(undefined) + queryClient.setQueryData(key, () => undefined) + expect(queryClient.getQueryCache().find(key)).toBe(undefined) + }) + + test('should not update query data if data is undefined', () => { + const key = queryKey() + queryClient.setQueryData(key, 'qux') + queryClient.setQueryData(key, undefined) + expect(queryClient.getQueryData(key)).toBe('qux') + }) + + test('should not update query data if updater returns undefined', () => { + const key = queryKey() + queryClient.setQueryData(key, 'qux') + queryClient.setQueryData(key, () => undefined) + expect(queryClient.getQueryData(key)).toBe('qux') + }) + test('should accept an update function', () => { const key = queryKey() @@ -871,8 +899,8 @@ describe('queryClient', () => { test('should refetch all queries when no arguments are given', async () => { const key1 = queryKey() const key2 = queryKey() - const queryFn1 = jest.fn() - const queryFn2 = jest.fn() + const queryFn1 = jest.fn().mockReturnValue('data1') + const queryFn2 = jest.fn().mockReturnValue('data2') await queryClient.fetchQuery(key1, queryFn1) await queryClient.fetchQuery(key2, queryFn2) const observer1 = new QueryObserver(queryClient, { @@ -897,8 +925,8 @@ describe('queryClient', () => { test('should be able to refetch all fresh queries', async () => { const key1 = queryKey() const key2 = queryKey() - const queryFn1 = jest.fn() - const queryFn2 = jest.fn() + const queryFn1 = jest.fn().mockReturnValue('data1') + const queryFn2 = jest.fn().mockReturnValue('data2') await queryClient.fetchQuery(key1, queryFn1) await queryClient.fetchQuery(key2, queryFn2) const observer = new QueryObserver(queryClient, { @@ -916,8 +944,8 @@ describe('queryClient', () => { test('should be able to refetch all stale queries', async () => { const key1 = queryKey() const key2 = queryKey() - const queryFn1 = jest.fn() - const queryFn2 = jest.fn() + const queryFn1 = jest.fn().mockReturnValue('data1') + const queryFn2 = jest.fn().mockReturnValue('data2') await queryClient.fetchQuery(key1, queryFn1) await queryClient.fetchQuery(key2, queryFn2) const observer = new QueryObserver(queryClient, { @@ -936,8 +964,8 @@ describe('queryClient', () => { test('should be able to refetch all stale and active queries', async () => { const key1 = queryKey() const key2 = queryKey() - const queryFn1 = jest.fn() - const queryFn2 = jest.fn() + const queryFn1 = jest.fn().mockReturnValue('data1') + const queryFn2 = jest.fn().mockReturnValue('data2') await queryClient.fetchQuery(key1, queryFn1) await queryClient.fetchQuery(key2, queryFn2) queryClient.invalidateQueries(key1) @@ -958,8 +986,8 @@ describe('queryClient', () => { test('should be able to refetch all active and inactive queries', async () => { const key1 = queryKey() const key2 = queryKey() - const queryFn1 = jest.fn() - const queryFn2 = jest.fn() + const queryFn1 = jest.fn().mockReturnValue('data1') + const queryFn2 = jest.fn().mockReturnValue('data2') await queryClient.fetchQuery(key1, queryFn1) await queryClient.fetchQuery(key2, queryFn2) const observer = new QueryObserver(queryClient, { @@ -977,8 +1005,8 @@ describe('queryClient', () => { test('should be able to refetch all active and inactive queries', async () => { const key1 = queryKey() const key2 = queryKey() - const queryFn1 = jest.fn() - const queryFn2 = jest.fn() + const queryFn1 = jest.fn().mockReturnValue('data1') + const queryFn2 = jest.fn().mockReturnValue('data2') await queryClient.fetchQuery(key1, queryFn1) await queryClient.fetchQuery(key2, queryFn2) const observer = new QueryObserver(queryClient, { @@ -996,8 +1024,8 @@ describe('queryClient', () => { test('should be able to refetch only active queries', async () => { const key1 = queryKey() const key2 = queryKey() - const queryFn1 = jest.fn() - const queryFn2 = jest.fn() + const queryFn1 = jest.fn().mockReturnValue('data1') + const queryFn2 = jest.fn().mockReturnValue('data2') await queryClient.fetchQuery(key1, queryFn1) await queryClient.fetchQuery(key2, queryFn2) const observer = new QueryObserver(queryClient, { @@ -1015,8 +1043,8 @@ describe('queryClient', () => { test('should be able to refetch only inactive queries', async () => { const key1 = queryKey() const key2 = queryKey() - const queryFn1 = jest.fn() - const queryFn2 = jest.fn() + const queryFn1 = jest.fn().mockReturnValue('data1') + const queryFn2 = jest.fn().mockReturnValue('data2') await queryClient.fetchQuery(key1, queryFn1) await queryClient.fetchQuery(key2, queryFn2) const observer = new QueryObserver(queryClient, { @@ -1060,8 +1088,8 @@ describe('queryClient', () => { test('should refetch active queries by default', async () => { const key1 = queryKey() const key2 = queryKey() - const queryFn1 = jest.fn() - const queryFn2 = jest.fn() + const queryFn1 = jest.fn().mockReturnValue('data1') + const queryFn2 = jest.fn().mockReturnValue('data2') await queryClient.fetchQuery(key1, queryFn1) await queryClient.fetchQuery(key2, queryFn2) const observer = new QueryObserver(queryClient, { @@ -1079,8 +1107,8 @@ describe('queryClient', () => { test('should not refetch inactive queries by default', async () => { const key1 = queryKey() const key2 = queryKey() - const queryFn1 = jest.fn() - const queryFn2 = jest.fn() + const queryFn1 = jest.fn().mockReturnValue('data1') + const queryFn2 = jest.fn().mockReturnValue('data2') await queryClient.fetchQuery(key1, queryFn1) await queryClient.fetchQuery(key2, queryFn2) const observer = new QueryObserver(queryClient, { @@ -1098,8 +1126,8 @@ describe('queryClient', () => { test('should not refetch active queries when "refetch" is "none"', async () => { const key1 = queryKey() const key2 = queryKey() - const queryFn1 = jest.fn() - const queryFn2 = jest.fn() + const queryFn1 = jest.fn().mockReturnValue('data1') + const queryFn2 = jest.fn().mockReturnValue('data2') await queryClient.fetchQuery(key1, queryFn1) await queryClient.fetchQuery(key2, queryFn2) const observer = new QueryObserver(queryClient, { @@ -1119,8 +1147,8 @@ describe('queryClient', () => { test('should refetch inactive queries when "refetch" is "inactive"', async () => { const key1 = queryKey() const key2 = queryKey() - const queryFn1 = jest.fn() - const queryFn2 = jest.fn() + const queryFn1 = jest.fn().mockReturnValue('data1') + const queryFn2 = jest.fn().mockReturnValue('data2') await queryClient.fetchQuery(key1, queryFn1) await queryClient.fetchQuery(key2, queryFn2) const observer = new QueryObserver(queryClient, { @@ -1142,8 +1170,8 @@ describe('queryClient', () => { test('should refetch active and inactive queries when "refetch" is "all"', async () => { const key1 = queryKey() const key2 = queryKey() - const queryFn1 = jest.fn() - const queryFn2 = jest.fn() + const queryFn1 = jest.fn().mockReturnValue('data1') + const queryFn2 = jest.fn().mockReturnValue('data2') await queryClient.fetchQuery(key1, queryFn1) await queryClient.fetchQuery(key2, queryFn2) const observer = new QueryObserver(queryClient, { @@ -1269,8 +1297,8 @@ describe('queryClient', () => { test('should refetch all active queries', async () => { const key1 = queryKey() const key2 = queryKey() - const queryFn1 = jest.fn() - const queryFn2 = jest.fn() + const queryFn1 = jest.fn().mockReturnValue('data1') + const queryFn2 = jest.fn().mockReturnValue('data2') const observer1 = new QueryObserver(queryClient, { queryKey: key1, queryFn: queryFn1, diff --git a/src/core/tests/queryObserver.test.tsx b/src/core/tests/queryObserver.test.tsx index 9daf7d8207..ed95023aaf 100644 --- a/src/core/tests/queryObserver.test.tsx +++ b/src/core/tests/queryObserver.test.tsx @@ -331,7 +331,7 @@ describe('queryObserver', () => { test('should be able to watch a query without defining a query function', async () => { const key = queryKey() - const queryFn = jest.fn() + const queryFn = jest.fn().mockReturnValue('data') const callback = jest.fn() const observer = new QueryObserver(queryClient, { queryKey: key, @@ -346,7 +346,7 @@ describe('queryObserver', () => { test('should accept unresolved query config in update function', async () => { const key = queryKey() - const queryFn = jest.fn() + const queryFn = jest.fn().mockReturnValue('data') const observer = new QueryObserver(queryClient, { queryKey: key, enabled: false, diff --git a/src/core/types.ts b/src/core/types.ts index f177030963..9df4e6a954 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -6,10 +6,14 @@ import type { QueryCache } from './queryCache' import type { MutationCache } from './mutationCache' export type QueryKey = readonly unknown[] +export type QueryFunctionData<T> = T extends undefined ? never : T + export type QueryFunction< T = unknown, TQueryKey extends QueryKey = QueryKey -> = (context: QueryFunctionContext<TQueryKey>) => T | Promise<T> +> = ( + context: QueryFunctionContext<TQueryKey> +) => QueryFunctionData<T> | Promise<QueryFunctionData<T>> export interface QueryFunctionContext< TQueryKey extends QueryKey = QueryKey, diff --git a/src/reactjs/tests/ssr.test.tsx b/src/reactjs/tests/ssr.test.tsx index 2363a160c0..1a2c738331 100644 --- a/src/reactjs/tests/ssr.test.tsx +++ b/src/reactjs/tests/ssr.test.tsx @@ -60,7 +60,10 @@ describe('Server Side Rendering', () => { const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) const key = queryKey() - const queryFn = jest.fn(() => sleep(10)) + const queryFn = jest.fn(() => { + sleep(10) + return 'data' + }) function Page() { const query = useQuery(key, queryFn) diff --git a/src/reactjs/tests/suspense.test.tsx b/src/reactjs/tests/suspense.test.tsx index a4b68d4210..6bf53b56ea 100644 --- a/src/reactjs/tests/suspense.test.tsx +++ b/src/reactjs/tests/suspense.test.tsx @@ -114,7 +114,10 @@ describe("useQuery's in Suspense mode", () => { const key = queryKey() const queryFn = jest.fn() - queryFn.mockImplementation(() => sleep(10)) + queryFn.mockImplementation(() => { + sleep(10) + return 'data' + }) function Page() { useQuery([key], queryFn, { suspense: true }) @@ -138,7 +141,14 @@ describe("useQuery's in Suspense mode", () => { const key = queryKey() function Page() { - useQuery(key, () => sleep(10), { suspense: true }) + useQuery( + key, + () => { + sleep(10) + return 'data' + }, + { suspense: true } + ) return <>rendered</> } @@ -212,10 +222,17 @@ describe("useQuery's in Suspense mode", () => { const successFn2 = jest.fn() function FirstComponent() { - useQuery(key, () => sleep(10), { - suspense: true, - onSuccess: successFn1, - }) + useQuery( + key, + () => { + sleep(10) + return 'data' + }, + { + suspense: true, + onSuccess: successFn1, + } + ) return <span>first</span> } diff --git a/src/reactjs/tests/useQuery.test.tsx b/src/reactjs/tests/useQuery.test.tsx index 93e1ea3e52..6a68bd126d 100644 --- a/src/reactjs/tests/useQuery.test.tsx +++ b/src/reactjs/tests/useQuery.test.tsx @@ -3447,7 +3447,7 @@ describe('useQuery', () => { const [enabled, setEnabled] = React.useState(false) const [isPrefetched, setPrefetched] = React.useState(false) - const query = useQuery(key, () => undefined, { + const query = useQuery(key, () => 'data', { enabled, }) @@ -3609,7 +3609,7 @@ describe('useQuery', () => { const key = queryKey() function Page() { - const query = useQuery(key, () => undefined, { + const query = useQuery(key, () => 'data', { enabled: false, })