diff --git a/docs/framework/react/reference/useQuery.md b/docs/framework/react/reference/useQuery.md index 54be8ac837..9fc50ced2e 100644 --- a/docs/framework/react/reference/useQuery.md +++ b/docs/framework/react/reference/useQuery.md @@ -43,6 +43,9 @@ const { notifyOnChangeProps, placeholderData, queryKeyHashFn, + onSuccess, + onError, + onSettled, refetchInterval, refetchIntervalInBackground, refetchOnMount, @@ -106,6 +109,18 @@ const { - `queryKeyHashFn: (queryKey: QueryKey) => string` - Optional - If specified, this function is used to hash the `queryKey` to a string. +- `onSuccess: (data: TData | undefined) => Promise | unknown` + - Optional + - This function will fire when the `queryFn` is successful and will be passed the `data`. + - Void function, the returned value will be ignored +- `onError: (error: TError | null) => Promise | unknown` + - Optional + - This function will fire if the `queryFn` encounters an error and will be passed the `error` + - Void function, the returned value will be ignored +- `onSettled: (data: TData | undefined, error: TError | null) => Promise | unknown` + - Optional + - This function will fire when the `queryFn` is either successfully fetched or encounters an error and be passed either the `data` or `error` + - Void function, the returned value will be ignored - `refetchInterval: number | false | ((query: Query) => number | false | undefined)` - Optional - If set to a number, all queries will continuously refetch at this frequency in milliseconds diff --git a/docs/framework/solid/reference/useQuery.md b/docs/framework/solid/reference/useQuery.md index e3c042618a..99b1771d6e 100644 --- a/docs/framework/solid/reference/useQuery.md +++ b/docs/framework/solid/reference/useQuery.md @@ -43,6 +43,9 @@ const { initialDataUpdatedAt, meta, queryKeyHashFn, + onSuccess, + onError, + onSettled, refetchInterval, refetchIntervalInBackground, refetchOnMount, @@ -244,6 +247,18 @@ function App() { - ##### `queryKeyHashFn: (queryKey: QueryKey) => string` - Optional - If specified, this function is used to hash the `queryKey` to a string. + - ##### `onSuccess: (data: TData | undefined) => Promise | unknown` + - Optional + - This function will fire when the `queryFn` is successful and will be passed the `data`. + - Void function, the returned value will be ignored + - ##### `onError: (error: TError | null) => Promise | unknown` + - Optional + - This function will fire if the `queryFn` encounters an error and will be passed the `error` + - Void function, the returned value will be ignored + - ##### `onSettled: (data: TData | undefined, error: TError | null) => Promise | unknown` + - Optional + - This function will fire when the `queryFn` is either successfully fetched or encounters an error and be passed either the `data` or `error` + - Void function, the returned value will be ignored - ##### `refetchInterval: number | false | ((query: Query) => number | false | undefined)` - Optional - If set to a number, all queries will continuously refetch at this frequency in milliseconds diff --git a/examples/react/nextjs-suspense-streaming/tsconfig.json b/examples/react/nextjs-suspense-streaming/tsconfig.json index 5f9ae00167..f8f3a67416 100644 --- a/examples/react/nextjs-suspense-streaming/tsconfig.json +++ b/examples/react/nextjs-suspense-streaming/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES5", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -12,7 +16,7 @@ "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { @@ -27,5 +31,7 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": [ + "node_modules" + ] } diff --git a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts index 2f541788ab..b94aaca816 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -766,4 +766,37 @@ describe('injectQuery', () => { expect(callCount).toBe(2) }) }) + + + test('callbacks `onSuccess`, `onError` and `onSettled` should be called after a successful fetch', async () => { + const onSuccessMock = vi.fn() + const onFailureMock = vi.fn() + const onSuccessSettledMock = vi.fn() + const onFailureSettledMock = vi.fn() + + + TestBed.runInInjectionContext(() => { + injectQuery(() => ({ + queryKey: ['expected-success'], + queryFn: () => 'fetched', + onSuccess: (data) => onSuccessMock(data), + onSettled: (data, _) => onSuccessSettledMock(data), + })); + + injectQuery(() => ({ + queryKey: ['expected-failure'], + queryFn: () => Promise.reject(new Error('error')), + onError: (error) => onFailureMock(error), + onSettled: (_, error) => onFailureSettledMock(error), + retry: false, + })); + }) + + await vi.advanceTimersByTimeAsync(10) + + expect(onSuccessMock).toHaveBeenCalled() + expect(onSuccessSettledMock).toHaveBeenCalled() + expect(onFailureMock).toHaveBeenCalled() + expect(onFailureSettledMock).toHaveBeenCalled() + }) }) diff --git a/packages/query-core/src/__tests__/query.test.tsx b/packages/query-core/src/__tests__/query.test.tsx index a33bf75cfc..4be6d038e3 100644 --- a/packages/query-core/src/__tests__/query.test.tsx +++ b/packages/query-core/src/__tests__/query.test.tsx @@ -1305,6 +1305,29 @@ describe('query', () => { }) }) + test('query options callbacks should be called in correct order with correct arguments for error case', async () => { + const onError = vi.fn() + const onSettled = vi.fn() + + const key = queryKey() + + await expect( + queryClient.fetchQuery({ + queryKey: key, + queryFn: () => Promise.reject(new Error('error')), + retry: false, + onError, + onSettled, + }), + ).rejects.toBeDefined() + + expect(onError).toHaveBeenCalledTimes(1) + expect(onError.mock.calls[0]![0]).toEqual(expect.any(Error)) + + expect(onSettled).toHaveBeenCalledTimes(1) + expect(onSettled).toHaveBeenCalledWith(undefined, expect.any(Error)) + }) + test('should not increment dataUpdateCount when setting initialData on prefetched query', async () => { const key = queryKey() const queryFn = vi.fn().mockImplementation(() => 'fetched-data') @@ -1347,4 +1370,34 @@ describe('query', () => { const updatedResult = observer.getCurrentResult() expect(updatedResult.isFetchedAfterMount).toBe(true) }) + + + test('callbacks `onSuccess`, `onError` and `onSettled` should be called', async () => { + const onSuccessMock = vi.fn() + const onFailureMock = vi.fn() + const onSuccessSettledMock = vi.fn() + const onFailureSettledMock = vi.fn() + + await queryClient.fetchQuery({ + queryKey: ['expected-success'], + queryFn: () => 'fetched', + onSuccess: onSuccessMock, + onSettled: onSuccessSettledMock, + }) + + await expect( + queryClient.fetchQuery({ + queryKey: ['expected-failure'], + queryFn: () => Promise.reject(new Error('error')), + onError: (error) => onFailureMock(error), + onSettled: (_, error) => onFailureSettledMock(error), + retry: false + }) + ).rejects.toBeDefined() + + expect(onSuccessMock).toHaveBeenCalled() + expect(onSuccessSettledMock).toHaveBeenCalled() + expect(onFailureMock).toHaveBeenCalled() + expect(onFailureSettledMock).toHaveBeenCalled() + }) }) diff --git a/packages/query-core/src/__tests__/queryObserver.test.tsx b/packages/query-core/src/__tests__/queryObserver.test.tsx index c1ddefbfa5..2f8e32ba02 100644 --- a/packages/query-core/src/__tests__/queryObserver.test.tsx +++ b/packages/query-core/src/__tests__/queryObserver.test.tsx @@ -934,6 +934,62 @@ describe('queryObserver', () => { expect(observer.getCurrentResult().data).toBe(selectedData2) }) + test('observer callbacks should be called in correct order with correct arguments for success case', async () => { + const onSuccess = vi.fn() + const onSettled = vi.fn() + + const key = queryKey() + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => Promise.resolve('SUCCESS'), + onSuccess, + onSettled, + }) + + const unsubscribe = observer.subscribe(() => undefined) + + await vi.advanceTimersByTimeAsync(0) + + expect(onSuccess).toHaveBeenCalled() + expect(onSuccess).toHaveBeenLastCalledWith('SUCCESS') + + expect(onSettled).toHaveBeenCalled() + expect(onSettled).toHaveBeenLastCalledWith('SUCCESS', null) + + unsubscribe() + }) + + test('observer callbacks should be called in correct order with correct arguments for error case', async () => { + const onError = vi.fn() + const onSettled = vi.fn() + + const err = new Error('err') + const key = queryKey() + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => Promise.reject(err), + retry: false, + onError, + onSettled, + }) + + const unsubscribe = observer.subscribe(() => undefined) + + await vi.advanceTimersByTimeAsync(0) + + expect(onError).toHaveBeenCalled() + const onErrorLast = onError.mock.calls.at(-1)![0] + expect(onErrorLast).toEqual(expect.any(Error)) + expect(onErrorLast.message).toBe('err') + + expect(onSettled).toHaveBeenCalled() + expect(onSettled).toHaveBeenLastCalledWith(undefined, expect.any(Error)) + + unsubscribe() + }) + test('should pass the correct previous queryKey (from prevQuery) to placeholderData function params with select', async () => { const results: Array = [] const keys: Array | null> = [] diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index 6895c156db..ee2853e408 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -557,6 +557,13 @@ export class Query< this.setData(data) + // Notify per-query callback + await this.options.onSuccess?.(data) + await this.options.onSettled?.( + data, + this.state.error as any, + ) + // Notify cache callback this.#cache.config.onSuccess?.(data, this as Query) this.#cache.config.onSettled?.( @@ -586,6 +593,13 @@ export class Query< error: error as TError, }) + // Notify per-query callback + await this.options.onError?.(error as any) + await this.options.onSettled?.( + this.state.data, + error as any, + ) + // Notify cache callback this.#cache.config.onError?.( error as any, diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 92978673f6..b0aca5e6ca 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -155,7 +155,7 @@ export class QueryObserver< typeof this.options.enabled !== 'boolean' && typeof this.options.enabled !== 'function' && typeof resolveEnabled(this.options.enabled, this.#currentQuery) !== - 'boolean' + 'boolean' ) { throw new Error( 'Expected enabled to be a boolean or a callback that returns a boolean', @@ -199,9 +199,9 @@ export class QueryObserver< mounted && (this.#currentQuery !== prevQuery || resolveEnabled(this.options.enabled, this.#currentQuery) !== - resolveEnabled(prevOptions.enabled, this.#currentQuery) || + resolveEnabled(prevOptions.enabled, this.#currentQuery) || resolveStaleTime(this.options.staleTime, this.#currentQuery) !== - resolveStaleTime(prevOptions.staleTime, this.#currentQuery)) + resolveStaleTime(prevOptions.staleTime, this.#currentQuery)) ) { this.#updateStaleTimeout() } @@ -213,7 +213,7 @@ export class QueryObserver< mounted && (this.#currentQuery !== prevQuery || resolveEnabled(this.options.enabled, this.#currentQuery) !== - resolveEnabled(prevOptions.enabled, this.#currentQuery) || + resolveEnabled(prevOptions.enabled, this.#currentQuery) || nextRefetchInterval !== this.#currentRefetchInterval) ) { this.#updateRefetchInterval(nextRefetchInterval) @@ -502,11 +502,11 @@ export class QueryObserver< placeholderData = typeof options.placeholderData === 'function' ? ( - options.placeholderData as unknown as PlaceholderDataFunction - )( - this.#lastQueryWithDefinedData?.state.data, - this.#lastQueryWithDefinedData as any, - ) + options.placeholderData as unknown as PlaceholderDataFunction + )( + this.#lastQueryWithDefinedData?.state.data, + this.#lastQueryWithDefinedData as any, + ) : options.placeholderData } @@ -606,7 +606,7 @@ export class QueryObserver< const recreateThenable = () => { const pending = (this.#currentThenable = - nextResult.promise = + nextResult.promise = pendingThenable()) finalizeThenableIfPossible(pending) @@ -662,7 +662,7 @@ export class QueryObserver< return } - this.#currentResult = nextResult + this.#currentResult = nextResult const shouldNotifyListeners = (): boolean => { if (!prevResult) { @@ -698,7 +698,7 @@ export class QueryObserver< }) } - this.#notify({ listeners: shouldNotifyListeners() }) + this.#notify({ query: this.#currentQuery, listeners: shouldNotifyListeners() }) } #updateQuery(): void { @@ -728,9 +728,31 @@ export class QueryObserver< } } - #notify(notifyOptions: { listeners: boolean }): void { + #notify(notifyOptions: { query: Query, listeners: boolean }): void { notifyManager.batch(() => { - // First, trigger the listeners + // First trigger the mutate callbacks + if (this.hasListeners()) { + + if (notifyOptions.query.state.status === 'success') { + this.options.onSuccess?.( + notifyOptions.query.state.data + ) + this.options.onSettled?.( + notifyOptions.query.state.data, + null, + ) + } else if (notifyOptions.query.state.status === 'error') { + this.options.onError?.( + notifyOptions.query.state.error, + ) + this.options.onSettled?.( + undefined, + notifyOptions.query.state.error, + ) + } + } + + // Then trigger the listeners if (notifyOptions.listeners) { this.listeners.forEach((listener) => { listener(this.#currentResult) diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index ebfcf2c6bb..8a7c48a2c6 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -257,6 +257,16 @@ export interface QueryOptions< initialData?: TData | InitialDataFunction initialDataUpdatedAt?: number | (() => number | undefined) behavior?: QueryBehavior + onSuccess?: ( + data: TData | undefined, + ) => Promise | unknown + onError?: ( + error: TError | null, + ) => Promise | unknown + onSettled?: ( + data: TData | undefined, + error: TError | null, + ) => Promise | unknown /** * Set this to `false` to disable structural sharing between query results. * Set this to a function which accepts the old and new data and returns resolved data of the same type to implement custom structural sharing logic. diff --git a/packages/react-query/src/__tests__/useQuery.test.tsx b/packages/react-query/src/__tests__/useQuery.test.tsx index 39393379c0..8484e14731 100644 --- a/packages/react-query/src/__tests__/useQuery.test.tsx +++ b/packages/react-query/src/__tests__/useQuery.test.tsx @@ -6468,10 +6468,10 @@ describe('useQuery', () => { queryKey: [key], queryFn: enabled ? async () => { - await sleep(10) + await sleep(10) - return Promise.resolve('data') - } + return Promise.resolve('data') + } : skipToken, retry: false, retryOnMount: false, @@ -6775,4 +6775,38 @@ describe('useQuery', () => { consoleErrorMock.mockRestore() }) + it('callbacks `onSuccess`, `onError` and `onSettled` should be called', async () => { + const onSuccessMock = vi.fn() + const onFailureMock = vi.fn() + const onSuccessSettledMock = vi.fn() + const onFailureSettledMock = vi.fn() + + function Page() { + useQuery({ + queryKey: ['expected-success'], + queryFn: () => 'fetched', + onSuccess: (data) => onSuccessMock(data), + onSettled: (data, _) => onSuccessSettledMock(data), + }) + + useQuery({ + queryKey: ['expected-failure'], + queryFn: () => Promise.reject(new Error('error')), + onError: (error) => onFailureMock(error), + onSettled: (_, error) => onFailureSettledMock(error), + retry: false, + }) + + return null + } + + renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(10) + + expect(onSuccessMock).toHaveBeenCalled() + expect(onSuccessSettledMock).toHaveBeenCalled() + expect(onFailureMock).toHaveBeenCalled() + expect(onFailureSettledMock).toHaveBeenCalled() + }) }) diff --git a/packages/solid-query/src/__tests__/useQuery.test.tsx b/packages/solid-query/src/__tests__/useQuery.test.tsx index 799a8e240e..e8975c5af9 100644 --- a/packages/solid-query/src/__tests__/useQuery.test.tsx +++ b/packages/solid-query/src/__tests__/useQuery.test.tsx @@ -910,6 +910,7 @@ describe('useQuery', () => { expect(states[1]).toMatchObject({ data: 'test' }) }) + it('should throw an error when a selector throws', async () => { const key = queryKey() const states: Array<{ status: string; data?: unknown; error?: Error }> = [] @@ -6166,4 +6167,43 @@ describe('useQuery', () => { expect(rendered.getByText('Status: custom client')).toBeInTheDocument(), ) }) + + it('callbacks `onSuccess`, `onError` and `onSettled` should be called', async () => { + const onSuccessMock = vi.fn() + const onFailureMock = vi.fn() + const onSuccessSettledMock = vi.fn() + const onFailureSettledMock = vi.fn() + + function Page() { + useQuery(() => ({ + queryKey: ['expected-success'], + queryFn: () => 'fetched', + onSuccess: (data) => onSuccessMock(data), + onSettled: (data, _) => onSuccessSettledMock(data), + })) + + useQuery(() => ({ + queryKey: ['expected-failure'], + queryFn: () => Promise.reject(new Error('error')), + onError: (error) => onFailureMock(error), + onSettled: (_, error) => onFailureSettledMock(error), + retry: false + })) + + return null + } + + render(() => ( + + + + )) + + await sleep(15) + + expect(onSuccessMock).toHaveBeenCalled() + expect(onSuccessSettledMock).toHaveBeenCalled() + expect(onFailureMock).toHaveBeenCalled() + expect(onFailureSettledMock).toHaveBeenCalled() + }) }) diff --git a/packages/svelte-query/tests/createQuery.svelte.test.ts b/packages/svelte-query/tests/createQuery.svelte.test.ts index 9cec8a17b5..b56484fbad 100644 --- a/packages/svelte-query/tests/createQuery.svelte.test.ts +++ b/packages/svelte-query/tests/createQuery.svelte.test.ts @@ -1600,7 +1600,7 @@ describe('createQuery', () => { const query = createQuery( () => ({ queryKey: key, - queryFn: () => new Promise(() => {}), + queryFn: () => new Promise(() => { }), }), () => queryClient, ) @@ -1917,4 +1917,42 @@ describe('createQuery', () => { expect(queryClient2.getQueryCache().find({ queryKey: key })).toBeDefined() }), ) + + it( + 'callbacks `onSuccess`, `onError` and `onSettled` should be called', + withEffectRoot(async () => { + const onSuccessMock = vi.fn() + const onFailureMock = vi.fn() + const onSuccessSettledMock = vi.fn() + const onFailureSettledMock = vi.fn() + + createQuery( + () => ({ + queryKey: ['expected-success'], + queryFn: () => 'fetched', + onSuccess: (data) => onSuccessMock(data), + onSettled: (data, _) => onSuccessSettledMock(data), + }), + () => queryClient, + ) + + createQuery( + () => ({ + queryKey: ['expected-failure'], + queryFn: () => Promise.reject(new Error('error')), + onError: (error) => onFailureMock(error), + onSettled: (_, error) => onFailureSettledMock(error), + retry: false, + }), + () => queryClient, + ) + + await sleep(10) + + expect(onSuccessMock).toHaveBeenCalled() + expect(onSuccessSettledMock).toHaveBeenCalled() + expect(onFailureMock).toHaveBeenCalled() + expect(onFailureSettledMock).toHaveBeenCalled() + }), + ) }) diff --git a/packages/vue-query/src/__tests__/useQuery.test.ts b/packages/vue-query/src/__tests__/useQuery.test.ts index 316185abde..a88e83eb0f 100644 --- a/packages/vue-query/src/__tests__/useQuery.test.ts +++ b/packages/vue-query/src/__tests__/useQuery.test.ts @@ -570,4 +570,33 @@ describe('useQuery', () => { ) }) }) + + test('callbacks `onSuccess`, `onError` and `onSettled` should be called', async () => { + const onSuccessMock = vi.fn() + const onFailureMock = vi.fn() + const onSuccessSettledMock = vi.fn() + const onFailureSettledMock = vi.fn() + + useQuery({ + queryKey: ['expected-success'], + queryFn: () => 'fetched', + onSuccess: (data) => onSuccessMock(data), + onSettled: (data, _) => onSuccessSettledMock(data), + }) + + useQuery({ + queryKey: ['expected-failure'], + queryFn: () => Promise.reject(new Error('error')), + onError: (error) => onFailureMock(error), + onSettled: (_, error) => onFailureSettledMock(error), + retry: false, + }) + + await vi.advanceTimersByTimeAsync(10) + + expect(onSuccessMock).toHaveBeenCalled() + expect(onSuccessSettledMock).toHaveBeenCalled() + expect(onFailureMock).toHaveBeenCalled() + expect(onFailureSettledMock).toHaveBeenCalled() + }) })