diff --git a/docs/src/pages/guides/migrating-to-react-query-4.md b/docs/src/pages/guides/migrating-to-react-query-4.md index 01a7d79c84..da8fc13b92 100644 --- a/docs/src/pages/guides/migrating-to-react-query-4.md +++ b/docs/src/pages/guides/migrating-to-react-query-4.md @@ -148,6 +148,10 @@ Since these plugins are no longer experimental, their import paths have also bee + import { createAsyncStoragePersister } from 'react-query/createAsyncStoragePersister' ``` +### The `cancel` method on promises is no longer supported + +The [old `cancel` method](../guides/query-cancellation#old-cancel-function) that allowed you to define a `cancel` function on promises, which was then used by the library to support query cancellation, has been removed. We recommend to use the [newer API](../guides/query-cancellation) (introduced with v3.30.0) for query cancellation that uses the [`AbortController` API](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) internally and provides you with an [`AbortSignal` instance](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) for your query function to support query cancellation. + ## New Features 🚀 ### Mutation Cache Garbage Collection diff --git a/src/core/infiniteQueryBehavior.ts b/src/core/infiniteQueryBehavior.ts index 0bd46be8e0..3fe93a0377 100644 --- a/src/core/infiniteQueryBehavior.ts +++ b/src/core/infiniteQueryBehavior.ts @@ -1,5 +1,5 @@ import type { QueryBehavior } from './query' -import { isCancelable } from './retryer' + import type { InfiniteData, QueryFunctionContext, @@ -73,11 +73,6 @@ export function infiniteQueryBehavior< buildNewPages(pages, param, page, previous) ) - if (isCancelable(queryFnResult)) { - const promiseAsAny = promise as any - promiseAsAny.cancel = queryFnResult.cancel - } - return promise } @@ -148,15 +143,10 @@ export function infiniteQueryBehavior< pageParams: newPageParams, })) - const finalPromiseAsAny = finalPromise as any - - finalPromiseAsAny.cancel = () => { + context.signal?.addEventListener('abort', () => { cancelled = true abortController?.abort() - if (isCancelable(promise)) { - promise.cancel() - } - } + }) return finalPromise } diff --git a/src/core/query.ts b/src/core/query.ts index 643825e381..d939387490 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -65,6 +65,7 @@ export interface FetchContext< > { fetchFn: () => unknown | Promise fetchOptions?: FetchOptions + signal?: AbortSignal options: QueryOptions queryKey: EnsuredQueryKey state: QueryState @@ -316,7 +317,7 @@ export class Query< // If the transport layer does not support cancellation // we'll let the query continue so the result can be cached if (this.retryer) { - if (this.retryer.isTransportCancelable || this.abortSignalConsumed) { + if (this.abortSignalConsumed) { this.retryer.cancel({ revert: true }) } else { this.retryer.cancelRetry() @@ -382,16 +383,23 @@ export class Query< meta: this.meta, } - Object.defineProperty(queryFnContext, 'signal', { - enumerable: true, - get: () => { - if (abortController) { - this.abortSignalConsumed = true - return abortController.signal - } - return undefined - }, - }) + // Adds an enumerable signal property to the object that + // which sets abortSignalConsumed to true when the signal + // is read. + const addSignalProperty = (object: unknown) => { + Object.defineProperty(object, 'signal', { + enumerable: true, + get: () => { + if (abortController) { + this.abortSignalConsumed = true + return abortController.signal + } + return undefined + }, + }) + } + + addSignalProperty(queryFnContext) // Create fetch function const fetchFn = () => { @@ -412,6 +420,8 @@ export class Query< meta: this.meta, } + addSignalProperty(context) + if (this.options.behavior?.onFetch) { this.options.behavior?.onFetch(context) } diff --git a/src/core/retryer.ts b/src/core/retryer.ts index d1c6e162d0..82e4d53792 100644 --- a/src/core/retryer.ts +++ b/src/core/retryer.ts @@ -34,15 +34,6 @@ type RetryDelayFunction = ( function defaultRetryDelay(failureCount: number) { return Math.min(1000 * 2 ** failureCount, 30000) } - -interface Cancelable { - cancel(): void -} - -export function isCancelable(value: any): value is Cancelable { - return typeof value?.cancel === 'function' -} - export class CancelledError { revert?: boolean silent?: boolean @@ -65,7 +56,6 @@ export class Retryer { failureCount: number isPaused: boolean isResolved: boolean - isTransportCancelable: boolean promise: Promise private abort?: () => void @@ -86,7 +76,6 @@ export class Retryer { this.failureCount = 0 this.isPaused = false this.isResolved = false - this.isTransportCancelable = false this.promise = new Promise((outerResolve, outerReject) => { promiseResolve = outerResolve promiseReject = outerReject @@ -144,19 +133,9 @@ export class Retryer { reject(new CancelledError(cancelOptions)) this.abort?.() - - // Cancel transport if supported - if (isCancelable(promiseOrValue)) { - try { - promiseOrValue.cancel() - } catch {} - } } } - // Check if the transport layer support cancellation - this.isTransportCancelable = isCancelable(promiseOrValue) - Promise.resolve(promiseOrValue) .then(resolve) .catch(error => { diff --git a/src/core/tests/query.test.tsx b/src/core/tests/query.test.tsx index 5bd06adf2d..ab4c7175a9 100644 --- a/src/core/tests/query.test.tsx +++ b/src/core/tests/query.test.tsx @@ -329,43 +329,6 @@ describe('query', () => { expect(isCancelledError(error)).toBe(true) }) - test('should call cancel() fn if it was provided and not continue when last observer unsubscribed', async () => { - const key = queryKey() - - const cancel = jest.fn() - - queryClient.prefetchQuery(key, async () => { - const promise = new Promise((resolve, reject) => { - sleep(100).then(() => resolve('data')) - cancel.mockImplementation(() => { - reject(new Error('Cancelled')) - }) - }) as any - promise.cancel = cancel - return promise - }) - - await sleep(10) - - // Subscribe and unsubscribe to simulate cancellation because the last observer unsubscribed - const observer = new QueryObserver(queryClient, { - queryKey: key, - enabled: false, - }) - const unsubscribe = observer.subscribe() - unsubscribe() - - await sleep(100) - - const query = queryCache.find(key)! - - expect(cancel).toHaveBeenCalled() - expect(query.state).toMatchObject({ - data: undefined, - status: 'idle', - }) - }) - test('should not continue if explicitly cancelled', async () => { const key = queryKey() diff --git a/src/core/tests/queryClient.test.tsx b/src/core/tests/queryClient.test.tsx index 16fe2936f7..71b3de40bf 100644 --- a/src/core/tests/queryClient.test.tsx +++ b/src/core/tests/queryClient.test.tsx @@ -927,7 +927,7 @@ describe('queryClient', () => { test('should cancel ongoing fetches if cancelRefetch option is set (default value)', async () => { const key = queryKey() - const cancelFn = jest.fn() + const abortFn = jest.fn() let fetchCount = 0 const observer = new QueryObserver(queryClient, { queryKey: key, @@ -936,25 +936,29 @@ describe('queryClient', () => { }) observer.subscribe() - queryClient.fetchQuery(key, () => { + queryClient.fetchQuery(key, ({ signal }) => { const promise = new Promise(resolve => { fetchCount++ setTimeout(() => resolve(5), 10) + if (signal) { + signal.addEventListener('abort', abortFn) + } }) - // @ts-expect-error - promise.cancel = cancelFn + return promise }) await queryClient.refetchQueries() observer.destroy() - expect(cancelFn).toHaveBeenCalledTimes(1) + if (typeof AbortSignal === 'function') { + expect(abortFn).toHaveBeenCalledTimes(1) + } expect(fetchCount).toBe(2) }) test('should not cancel ongoing fetches if cancelRefetch option is set to false', async () => { const key = queryKey() - const cancelFn = jest.fn() + const abortFn = jest.fn() let fetchCount = 0 const observer = new QueryObserver(queryClient, { queryKey: key, @@ -963,19 +967,23 @@ describe('queryClient', () => { }) observer.subscribe() - queryClient.fetchQuery(key, () => { + queryClient.fetchQuery(key, ({ signal }) => { const promise = new Promise(resolve => { fetchCount++ setTimeout(() => resolve(5), 10) + if (signal) { + signal.addEventListener('abort', abortFn) + } }) - // @ts-expect-error - promise.cancel = cancelFn + return promise }) await queryClient.refetchQueries(undefined, { cancelRefetch: false }) observer.destroy() - expect(cancelFn).toHaveBeenCalledTimes(0) + if (typeof AbortSignal === 'function') { + expect(abortFn).toHaveBeenCalledTimes(0) + } expect(fetchCount).toBe(1) }) }) diff --git a/src/reactjs/tests/useInfiniteQuery.test.tsx b/src/reactjs/tests/useInfiniteQuery.test.tsx index 540b44956d..3d4f581396 100644 --- a/src/reactjs/tests/useInfiniteQuery.test.tsx +++ b/src/reactjs/tests/useInfiniteQuery.test.tsx @@ -1782,14 +1782,13 @@ describe('useInfiniteQuery', () => { const key = queryKey() let cancelFn: jest.Mock = jest.fn() - const queryFn = () => { + const queryFn = ({ signal }: { signal?: AbortSignal }) => { const promise = new Promise((resolve, reject) => { cancelFn = jest.fn(() => reject('Cancelled')) + signal?.addEventListener('abort', cancelFn) sleep(10).then(() => resolve('OK')) }) - ;(promise as any).cancel = cancelFn - return promise } @@ -1811,6 +1810,8 @@ describe('useInfiniteQuery', () => { await waitFor(() => rendered.getByText('off')) - expect(cancelFn).toHaveBeenCalled() + if (typeof AbortSignal === 'function') { + expect(cancelFn).toHaveBeenCalled() + } }) }) diff --git a/src/reactjs/tests/useQuery.test.tsx b/src/reactjs/tests/useQuery.test.tsx index c0a9a77584..403685afc7 100644 --- a/src/reactjs/tests/useQuery.test.tsx +++ b/src/reactjs/tests/useQuery.test.tsx @@ -3952,14 +3952,13 @@ describe('useQuery', () => { const key = queryKey() let cancelFn: jest.Mock = jest.fn() - const queryFn = () => { + const queryFn = ({ signal }: { signal?: AbortSignal }) => { const promise = new Promise((resolve, reject) => { cancelFn = jest.fn(() => reject('Cancelled')) + signal?.addEventListener('abort', cancelFn) sleep(10).then(() => resolve('OK')) }) - ;(promise as any).cancel = cancelFn - return promise } @@ -3981,7 +3980,9 @@ describe('useQuery', () => { await waitFor(() => rendered.getByText('off')) - expect(cancelFn).toHaveBeenCalled() + if (typeof AbortSignal === 'function') { + expect(cancelFn).toHaveBeenCalled() + } }) it('should cancel the query if the signal was consumed and there are no more subscriptions', async () => {