Repository: tannerlinsley/react-query
Pull request #2782 (2021-10-10 21:40:12)

feat(cancellation): Provide AbortSignal to queryFn
All queryFn calls will now have an AbortSignal instance passed to them
as part of the QueryFunctionContext. In environments that support the
AbortController/AbortSignal the consumer may use this `signal` as a
way to get notified about the query being cancelled. Consumers no
longer need to add a `.cancel()` function to the result of `queryFn()`

```ts
const query = useQuery('todos', async ({ signal }) => {
  const res = await fetch('/todos', {
    method: 'get',
    // Pass the signal to your request
    signal,
  });

  const json = await res.json();
  let data = null;

  if (!signal.aborted) {
    // do expensive CPU transformation of data
    data = expensiveTransform(json);
  }

  return data;
});
```

The previous behavior for cancellation will continue to work. If a
`.cancel()` function is provided as the result of calling `queryFn()`
the AbortSignal will not be notified when the cancellation happens.
If the runtime environment does not support AbortController/AbortSignal
the old behavior will continue to work, but the `signal` provided to
the `queryFn()` will not be notified when the cancellation happens.

Ref https://github.com/tannerlinsley/react-query/discussions/1671#discussioncomment-1310752
Ref https://github.com/tannerlinsley/react-query/discussions/1671#discussioncomment-1453013

Fix https://github.com/tannerlinsley/react-query/issues/2775
Fix https://github.com/tannerlinsley/react-query/issues/1893
Fix https://github.com/tannerlinsley/react-query/issues/1265
Fix https://github.com/tannerlinsley/react-query/issues/886

vercel[bot]: This pull request is being automatically deployed with Vercel ([learn more](https://vercel.link/github-learn-more)).  
To see the status of your deployment, click below or on the icon next to each commit.

🔍 Inspect: [https://vercel.com/tannerlinsley/react-query/6L85ftWFZmJJ5PumWcGCEF4THhG1](https://vercel.com/tannerlinsley/react-query/6L85ftWFZmJJ5PumWcGCEF4THhG1)  
✅ Preview: *In Progress*


TkDodo: can you please once more update with the latest master, so that we get bundle size changes reported? (they won't be posted on the issue, but visible in the pipeline). Thanks.
jcready: @TkDodo merged

Patch:
diff --git a/docs/src/pages/guides/query-cancellation.md b/docs/src/pages/guides/query-cancellation.md
index 1cef96dfd..cdeb57de7 100644
--- a/docs/src/pages/guides/query-cancellation.md
+++ b/docs/src/pages/guides/query-cancellation.md
@@ -3,20 +3,55 @@ id: query-cancellation
 title: Query Cancellation
 ---
 
-By default, queries that unmount or become unused before their promises are resolved are simply ignored instead of canceled. Why is this?
+[_Previous method requiring a `cancel` function_](#old-cancel-function)
 
-- For most applications, ignoring out-of-date queries is sufficient.
-- Cancellation APIs may not be available for every query function.
-- If cancellation APIs are available, they typically vary in implementation between utilities/libraries (eg. Fetch vs Axios vs XMLHttpRequest).
+React Query provides each query function with an [`AbortSignal` instance](https://developer.mozilla.org/docs/Web/API/AbortSignal) **if it's available in your runtime environment**. When a query becomes out-of-date or inactive, this `signal` will become aborted. This means that all queries are cancellable and you can respond to the cancellation inside your query function if desired. The best part about this is that it allow you to continue to use normal async/await syntax while getting all the benefits of automatic cancellation. Additionally, this solution works better with TypeScript than the old solution.
 
-But don't worry! If your queries are high-bandwidth or potentially very expensive to download, React Query exposes a generic way to **cancel** query requests using a cancellation token or other related API. To integrate with this feature, attach a `cancel` function to the promise returned by your query that implements your request cancellation. When a query becomes out-of-date or inactive, this `promise.cancel` function will be called (if available):
+The `AbortController` API is available in [most runtime environments](https://developer.mozilla.org/docs/Web/API/AbortController#browser_compatibility), but if the runtime environment does not support it then the query function will receive `undefined` in its place. You may choose to polyfill the `AbortController` API if you wish, there are [several available](https://www.npmjs.com/search?q=abortcontroller%20polyfill).
+
+## Using `fetch`
+
+```js
+const query = useQuery('todos', async ({ signal }) => {
+  const todosResponse = await fetch('/todos', {
+    // Pass the signal to one fetch
+    signal,
+  })
+  const todos = await todosResponse.json()
+
+  const todoDetails = todos.map(async ({ details } => {
+    const response = await fetch(details, {
+      // Or pass it to several
+      signal,
+    })
+    return response.json()
+  })
+
+  return Promise.all(todoDetails)
+})
+```
 
 ## Using `axios`
 
+### Using `axios` [v0.22.0+](https://github.com/axios/axios/releases/tag/v0.22.0)
+
 ```js
 import axios from 'axios'
 
-const query = useQuery('todos', () => {
+const query = useQuery('todos', ({ signal }) =>
+  axios.get('/todos', {
+    // Pass the signal to `axios`
+    signal,
+  })
+)
+```
+
+### Using an `axios` version less than v0.22.0
+
+```js
+import axios from 'axios'
+
+const query = useQuery('todos', ({ signal }) => {
   // Create a new CancelToken source for this request
   const CancelToken = axios.CancelToken
   const source = CancelToken.source()
@@ -26,50 +61,98 @@ const query = useQuery('todos', () => {
     cancelToken: source.token,
   })
 
-  // Cancel the request if React Query calls the `promise.cancel` method
-  promise.cancel = () => {
+  // Cancel the request if React Query signals to abort
+  signal?.addEventListener('abort', () => {
     source.cancel('Query was cancelled by React Query')
-  }
+  })
 
   return promise
 })
 ```
 
-## Using `fetch`
+## Using `XMLHttpRequest`
+
+```js
+const query = useQuery('todos', ({ signal }) => {
+  return new Promise((resolve, reject) => {
+    var oReq = new XMLHttpRequest()
+    oReq.addEventListener('load', () => {
+      resolve(JSON.parse(oReq.responseText))
+    })
+    signal?.addEventListener('abort', () => {
+      oReq.abort()
+      reject()
+    })
+    oReq.open('GET', '/todos')
+    oReq.send()
+  })
+})
+```
+
+## Manual Cancellation
+
+You might want to cancel a query manually. For example, if the request takes a long time to finish, you can allow the user to click a cancel button to stop the request. To do this, you just need to call `queryClient.cancelQueries(key)`. If `promise.cancel` is available or you have consumed the `singal` passed to the query function then React Query will cancel the request.
+
+```js
+const [queryKey] = useState('todos')
+
+const query = useQuery(queryKey, await ({ signal }) => {
+  const resp = fetch('/todos', { signal })
+  return resp.json()
+})
+
+const queryClient = useQueryClient()
+
+return (
+  <button onClick={(e) => {
+    e.preventDefault()
+    queryClient.cancelQueries(queryKey)
+   }}>Cancel</button>
+)
+```
+
+## Old `cancel` function
+
+Don't worry! The previous cancellation functionality will continue to work. But we do recommend that you move away from [the withdrawn cancelable promise proposal](https://github.com/tc39/proposal-cancelable-promises) to the [new `AbortSignal` interface](#_top) which has been [stardardized](https://dom.spec.whatwg.org/#interface-abortcontroller) as a general purpose construct for aborting ongoing activities in [most browsers](https://caniuse.com/abortcontroller) and in [Node](https://nodejs.org/api/globals.html#globals_class_abortsignal). The old cancel function might be removed in a future major version.
+
+To integrate with this feature, attach a `cancel` function to the promise returned by your query that implements your request cancellation. When a query becomes out-of-date or inactive, this `promise.cancel` function will be called (if available).
+
+## Using `axios` with `cancel` function
 
 ```js
+import axios from 'axios'
+
 const query = useQuery('todos', () => {
-  // Create a new AbortController instance for this request
-  const controller = new AbortController()
-  // Get the abortController's signal
-  const signal = controller.signal
+  // Create a new CancelToken source for this request
+  const CancelToken = axios.CancelToken
+  const source = CancelToken.source()
 
-  const promise = fetch('/todos', {
-    method: 'get',
-    // Pass the signal to your request
-    signal,
+  const promise = axios.get('/todos', {
+    // Pass the source token to your request
+    cancelToken: source.token,
   })
 
   // Cancel the request if React Query calls the `promise.cancel` method
-  promise.cancel = () => controller.abort()
+  promise.cancel = () => {
+    source.cancel('Query was cancelled by React Query')
+  }
 
   return promise
 })
 ```
 
-## Manual Cancellation
-
-You might want to cancel a query manually. For example, if the request takes a long time to finish, you can allow the user to click a cancel button to stop the request. To do this, you just need to call `queryClient.cancelQueries(key)`. If `promise.cancel` is available, React Query will cancel the request.
+## Using `fetch` with `cancel` function
 
 ```js
-const [queryKey] = useState('todos')
-
-const query = useQuery(queryKey, () => {
+const query = useQuery('todos', () => {
+  // Create a new AbortController instance for this request
   const controller = new AbortController()
+  // Get the abortController's signal
   const signal = controller.signal
 
   const promise = fetch('/todos', {
     method: 'get',
+    // Pass the signal to your request
     signal,
   })
 
@@ -78,13 +161,4 @@ const query = useQuery(queryKey, () => {
 
   return promise
 })
-
-const queryClient = useQueryClient();
-
-return (
-  <button onClick={(e) => {
-    e.preventDefault();
-    queryClient.cancelQueries(queryKey);
-   }}>Cancel</button>
-)
 ```
diff --git a/src/core/infiniteQueryBehavior.ts b/src/core/infiniteQueryBehavior.ts
index 502621bca..0bd46be8e 100644
--- a/src/core/infiniteQueryBehavior.ts
+++ b/src/core/infiniteQueryBehavior.ts
@@ -6,6 +6,7 @@ import type {
   QueryOptions,
   RefetchQueryFilters,
 } from './types'
+import { getAbortController } from './utils'
 
 export function infiniteQueryBehavior<
   TQueryFnData,
@@ -23,6 +24,8 @@ export function infiniteQueryBehavior<
         const isFetchingPreviousPage = fetchMore?.direction === 'backward'
         const oldPages = context.state.data?.pages || []
         const oldPageParams = context.state.data?.pageParams || []
+        const abortController = getAbortController()
+        const abortSignal = abortController?.signal
         let newPageParams = oldPageParams
         let cancelled = false
 
@@ -59,6 +62,7 @@ export function infiniteQueryBehavior<
 
           const queryFnContext: QueryFunctionContext = {
             queryKey: context.queryKey,
+            signal: abortSignal,
             pageParam: param,
             meta: context.meta,
           }
@@ -148,6 +152,7 @@ export function infiniteQueryBehavior<
 
         finalPromiseAsAny.cancel = () => {
           cancelled = true
+          abortController?.abort()
           if (isCancelable(promise)) {
             promise.cancel()
           }
diff --git a/src/core/query.ts b/src/core/query.ts
index 90c244f0e..336e9b874 100644
--- a/src/core/query.ts
+++ b/src/core/query.ts
@@ -1,4 +1,5 @@
 import {
+  getAbortController,
   Updater,
   functionalUpdate,
   isValidTimeout,
@@ -161,8 +162,10 @@ export class Query<
   private retryer?: Retryer<TData, TError>
   private observers: QueryObserver<any, any, any, any, any>[]
   private defaultOptions?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
+  private abortSignalConsumed: boolean
 
   constructor(config: QueryConfig<TQueryFnData, TError, TData, TQueryKey>) {
+    this.abortSignalConsumed = false
     this.defaultOptions = config.defaultOptions
     this.setOptions(config.options)
     this.observers = []
@@ -331,7 +334,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) {
+          if (this.retryer.isTransportCancelable || this.abortSignalConsumed) {
             this.retryer.cancel({ revert: true })
           } else {
             this.retryer.cancelRetry()
@@ -388,6 +391,7 @@ export class Query<
     }
 
     const queryKey = ensureQueryKeyArray(this.queryKey)
+    const abortController = getAbortController()
 
     // Create query function context
     const queryFnContext: QueryFunctionContext<TQueryKey> = {
@@ -396,11 +400,25 @@ export class Query<
       meta: this.meta,
     }
 
+    Object.defineProperty(queryFnContext, 'signal', {
+      enumerable: true,
+      get: () => {
+        if (abortController) {
+          this.abortSignalConsumed = true
+          return abortController.signal
+        }
+        return undefined
+      },
+    })
+
     // Create fetch function
-    const fetchFn = () =>
-      this.options.queryFn
-        ? this.options.queryFn(queryFnContext)
-        : Promise.reject('Missing queryFn')
+    const fetchFn = () => {
+      if (!this.options.queryFn) {
+        return Promise.reject('Missing queryFn')
+      }
+      this.abortSignalConsumed = false
+      return this.options.queryFn(queryFnContext)
+    }
 
     // Trigger behavior hook
     const context: FetchContext<TQueryFnData, TError, TData, TQueryKey> = {
@@ -430,6 +448,7 @@ export class Query<
     // Try to fetch the data
     this.retryer = new Retryer({
       fn: context.fetchFn as () => TData,
+      abort: abortController?.abort?.bind(abortController),
       onSuccess: data => {
         this.setData(data as TData)
 
diff --git a/src/core/retryer.ts b/src/core/retryer.ts
index 8c98dcb48..d1c6e162d 100644
--- a/src/core/retryer.ts
+++ b/src/core/retryer.ts
@@ -7,6 +7,7 @@ import { CancelOptions } from './types'
 
 interface RetryerConfig<TData = unknown, TError = unknown> {
   fn: () => TData | Promise<TData>
+  abort?: () => void
   onError?: (error: TError) => void
   onSuccess?: (data: TData) => void
   onFail?: (failureCount: number, error: TError) => void
@@ -67,6 +68,8 @@ export class Retryer<TData = unknown, TError = unknown> {
   isTransportCancelable: boolean
   promise: Promise<TData>
 
+  private abort?: () => void
+
   constructor(config: RetryerConfig<TData, TError>) {
     let cancelRetry = false
     let cancelFn: ((options?: CancelOptions) => void) | undefined
@@ -74,6 +77,7 @@ export class Retryer<TData = unknown, TError = unknown> {
     let promiseResolve: (data: TData) => void
     let promiseReject: (error: TError) => void
 
+    this.abort = config.abort
     this.cancel = cancelOptions => cancelFn?.(cancelOptions)
     this.cancelRetry = () => {
       cancelRetry = true
@@ -139,6 +143,8 @@ export class Retryer<TData = unknown, TError = unknown> {
         if (!this.isResolved) {
           reject(new CancelledError(cancelOptions))
 
+          this.abort?.()
+
           // Cancel transport if supported
           if (isCancelable(promiseOrValue)) {
             try {
diff --git a/src/core/types.ts b/src/core/types.ts
index 02661bb5a..2aa3dda46 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -18,6 +18,7 @@ export interface QueryFunctionContext<
   TPageParam = any
 > {
   queryKey: EnsuredQueryKey<TQueryKey>
+  signal?: AbortSignal
   pageParam?: TPageParam
   meta: QueryMeta | undefined
 }
diff --git a/src/core/utils.ts b/src/core/utils.ts
index 3e23dd357..1b0147450 100644
--- a/src/core/utils.ts
+++ b/src/core/utils.ts
@@ -447,3 +447,9 @@ export function scheduleMicrotask(callback: () => void): void {
       })
     )
 }
+
+export function getAbortController(): AbortController | undefined {
+  if (typeof AbortController === 'function') {
+    return new AbortController()
+  }
+}


Test patch:
diff --git a/src/core/tests/query.test.tsx b/src/core/tests/query.test.tsx
index 8801e8b58..ddf5f5a78 100644
--- a/src/core/tests/query.test.tsx
+++ b/src/core/tests/query.test.tsx
@@ -11,6 +11,7 @@ import {
   isCancelledError,
   isError,
   onlineManager,
+  QueryFunctionContext,
 } from '../..'
 
 describe('query', () => {
@@ -185,7 +186,30 @@ describe('query', () => {
     window.dispatchEvent(new FocusEvent('focus'))
   })
 
-  test('should continue if cancellation is not supported', async () => {
+  test('should provide context to queryFn', async () => {
+    const key = queryKey()
+
+    const queryFn = jest
+      .fn<Promise<'data'>, [QueryFunctionContext<string>]>()
+      .mockResolvedValue('data')
+
+    queryClient.prefetchQuery(key, queryFn)
+
+    await sleep(10)
+
+    expect(queryFn).toHaveBeenCalledTimes(1)
+    const args = queryFn.mock.calls[0]![0]
+    expect(args).toBeDefined()
+    expect(args.pageParam).toBeUndefined()
+    expect(args.queryKey).toEqual([key])
+    if (typeof AbortSignal === 'function') {
+      expect(args.signal).toBeInstanceOf(AbortSignal)
+    } else {
+      expect(args.signal).toBeUndefined()
+    }
+  })
+
+  test('should continue if cancellation is not supported and signal is not consumed', async () => {
     const key = queryKey()
 
     queryClient.prefetchQuery(key, async () => {
@@ -214,7 +238,98 @@ describe('query', () => {
     })
   })
 
-  test('should not continue if cancellation is supported', async () => {
+  test('should not continue when last observer unsubscribed if the signal was consumed', async () => {
+    const key = queryKey()
+
+    queryClient.prefetchQuery(key, async ({ signal }) => {
+      await sleep(100)
+      return signal?.aborted ? 'aborted' : 'data'
+    })
+
+    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)!
+
+    if (typeof AbortSignal === 'function') {
+      expect(query.state).toMatchObject({
+        data: undefined,
+        status: 'idle',
+      })
+    } else {
+      expect(query.state).toMatchObject({
+        data: 'data',
+        status: 'success',
+        dataUpdateCount: 1,
+      })
+    }
+  })
+
+  test('should provide an AbortSignal to the queryFn that provides info about the cancellation state', async () => {
+    const key = queryKey()
+
+    const queryFn = jest.fn<Promise<void>, [QueryFunctionContext<string>]>()
+    const onAbort = jest.fn()
+    const abortListener = jest.fn()
+    let error
+
+    queryFn.mockImplementation(async ({ signal }) => {
+      if (signal) {
+        signal.onabort = onAbort
+        signal.addEventListener('abort', abortListener)
+      }
+      await sleep(10)
+      if (signal) {
+        signal.onabort = null
+        signal.removeEventListener('abort', abortListener)
+      }
+      throw new Error()
+    })
+
+    const promise = queryClient.fetchQuery(key, queryFn, {
+      retry: 3,
+      retryDelay: 10,
+    })
+
+    promise.catch(e => {
+      error = e
+    })
+
+    const query = queryCache.find(key)!
+
+    expect(queryFn).toHaveBeenCalledTimes(1)
+
+    let signal = queryFn.mock.calls[0]![0].signal
+
+    if (typeof AbortSignal === 'function') {
+      signal = queryFn.mock.calls[0]![0].signal
+      expect(signal?.aborted).toBe(false)
+    }
+    expect(onAbort).not.toHaveBeenCalled()
+    expect(abortListener).not.toHaveBeenCalled()
+
+    query.cancel()
+
+    await sleep(100)
+
+    if (typeof AbortSignal === 'function') {
+      expect(signal?.aborted).toBe(true)
+      expect(onAbort).toHaveBeenCalledTimes(1)
+      expect(abortListener).toHaveBeenCalledTimes(1)
+    }
+    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()
diff --git a/src/react/tests/useInfiniteQuery.test.tsx b/src/react/tests/useInfiniteQuery.test.tsx
index 4c39ae34b..540b44956 100644
--- a/src/react/tests/useInfiniteQuery.test.tsx
+++ b/src/react/tests/useInfiniteQuery.test.tsx
@@ -14,6 +14,7 @@ import {
   UseInfiniteQueryResult,
   QueryClient,
   QueryCache,
+  QueryFunctionContext,
 } from '../..'
 
 interface Result {
@@ -750,7 +751,20 @@ describe('useInfiniteQuery', () => {
   it('should silently cancel an ongoing fetchNextPage request when another fetchNextPage is invoked', async () => {
     const key = queryKey()
     const start = 10
-    const fetchPage = jest.fn(async ({ pageParam = start }) => {
+    const onAborts: jest.Mock<any, any>[] = []
+    const abortListeners: jest.Mock<any, any>[] = []
+    const fetchPage = jest.fn<
+      Promise<number>,
+      [QueryFunctionContext<string, number>]
+    >(async ({ pageParam = start, signal }) => {
+      if (signal) {
+        const onAbort = jest.fn()
+        const abortListener = jest.fn()
+        onAborts.push(onAbort)
+        abortListeners.push(abortListener)
+        signal.onabort = onAbort
+        signal.addEventListener('abort', abortListener)
+      }
       await sleep(50)
       return Number(pageParam)
     })
@@ -776,25 +790,62 @@ describe('useInfiniteQuery', () => {
 
     await sleep(300)
 
-    expect(fetchPage).toBeCalledTimes(3)
-    expect(fetchPage).toHaveBeenNthCalledWith(1, {
-      pageParam: undefined,
-      queryKey: [key],
-    })
-    expect(fetchPage).toHaveBeenNthCalledWith(2, {
-      pageParam: 11,
-      queryKey: [key],
-    })
-    expect(fetchPage).toHaveBeenNthCalledWith(3, {
-      pageParam: 11,
-      queryKey: [key],
-    })
+    const expectedCallCount = 3
+    expect(fetchPage).toBeCalledTimes(expectedCallCount)
+    expect(onAborts).toHaveLength(expectedCallCount)
+    expect(abortListeners).toHaveLength(expectedCallCount)
+
+    let callIndex = 0
+    const firstCtx = fetchPage.mock.calls[callIndex]![0]
+    expect(firstCtx.pageParam).toBeUndefined()
+    expect(firstCtx.queryKey).toEqual([key])
+    if (typeof AbortSignal === 'function') {
+      expect(firstCtx.signal).toBeInstanceOf(AbortSignal)
+      expect(firstCtx.signal?.aborted).toBe(false)
+      expect(onAborts[callIndex]).not.toHaveBeenCalled()
+      expect(abortListeners[callIndex]).not.toHaveBeenCalled()
+    }
+
+    callIndex = 1
+    const secondCtx = fetchPage.mock.calls[callIndex]![0]
+    expect(secondCtx.pageParam).toBe(11)
+    expect(secondCtx.queryKey).toEqual([key])
+    if (typeof AbortSignal === 'function') {
+      expect(secondCtx.signal).toBeInstanceOf(AbortSignal)
+      expect(secondCtx.signal?.aborted).toBe(true)
+      expect(onAborts[callIndex]).toHaveBeenCalledTimes(1)
+      expect(abortListeners[callIndex]).toHaveBeenCalledTimes(1)
+    }
+
+    callIndex = 2
+    const thirdCtx = fetchPage.mock.calls[callIndex]![0]
+    expect(thirdCtx.pageParam).toBe(11)
+    expect(thirdCtx.queryKey).toEqual([key])
+    if (typeof AbortSignal === 'function') {
+      expect(thirdCtx.signal).toBeInstanceOf(AbortSignal)
+      expect(thirdCtx.signal?.aborted).toBe(false)
+      expect(onAborts[callIndex]).not.toHaveBeenCalled()
+      expect(abortListeners[callIndex]).not.toHaveBeenCalled()
+    }
   })
 
   it('should not cancel an ongoing fetchNextPage request when another fetchNextPage is invoked if `cancelRefetch: false` is used ', async () => {
     const key = queryKey()
     const start = 10
-    const fetchPage = jest.fn(async ({ pageParam = start }) => {
+    const onAborts: jest.Mock<any, any>[] = []
+    const abortListeners: jest.Mock<any, any>[] = []
+    const fetchPage = jest.fn<
+      Promise<number>,
+      [QueryFunctionContext<string, number>]
+    >(async ({ pageParam = start, signal }) => {
+      if (signal) {
+        const onAbort = jest.fn()
+        const abortListener = jest.fn()
+        onAborts.push(onAbort)
+        abortListeners.push(abortListener)
+        signal.onabort = onAbort
+        signal.addEventListener('abort', abortListener)
+      }
       await sleep(50)
       return Number(pageParam)
     })
@@ -820,15 +871,32 @@ describe('useInfiniteQuery', () => {
 
     await sleep(300)
 
-    expect(fetchPage).toBeCalledTimes(2)
-    expect(fetchPage).toHaveBeenNthCalledWith(1, {
-      pageParam: undefined,
-      queryKey: [key],
-    })
-    expect(fetchPage).toHaveBeenNthCalledWith(2, {
-      pageParam: 11,
-      queryKey: [key],
-    })
+    const expectedCallCount = 2
+    expect(fetchPage).toBeCalledTimes(expectedCallCount)
+    expect(onAborts).toHaveLength(expectedCallCount)
+    expect(abortListeners).toHaveLength(expectedCallCount)
+
+    let callIndex = 0
+    const firstCtx = fetchPage.mock.calls[callIndex]![0]
+    expect(firstCtx.pageParam).toBeUndefined()
+    expect(firstCtx.queryKey).toEqual([key])
+    if (typeof AbortSignal === 'function') {
+      expect(firstCtx.signal).toBeInstanceOf(AbortSignal)
+      expect(firstCtx.signal?.aborted).toBe(false)
+      expect(onAborts[callIndex]).not.toHaveBeenCalled()
+      expect(abortListeners[callIndex]).not.toHaveBeenCalled()
+    }
+
+    callIndex = 1
+    const secondCtx = fetchPage.mock.calls[callIndex]![0]
+    expect(secondCtx.pageParam).toBe(11)
+    expect(secondCtx.queryKey).toEqual([key])
+    if (typeof AbortSignal === 'function') {
+      expect(secondCtx.signal).toBeInstanceOf(AbortSignal)
+      expect(secondCtx.signal?.aborted).toBe(false)
+      expect(onAborts[callIndex]).not.toHaveBeenCalled()
+      expect(abortListeners[callIndex]).not.toHaveBeenCalled()
+    }
   })
 
   it('should keep fetching first page when not loaded yet and triggering fetch more', async () => {
diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx
index 87c64be79..547d5ddf1 100644
--- a/src/react/tests/useQuery.test.tsx
+++ b/src/react/tests/useQuery.test.tsx
@@ -3882,6 +3882,81 @@ describe('useQuery', () => {
     expect(cancelFn).toHaveBeenCalled()
   })
 
+  it('should cancel the query if the signal was consumed and there are no more subscriptions', async () => {
+    const key = queryKey()
+    const states: UseQueryResult<string, unknown>[] = []
+
+    const queryFn: QueryFunction<string, [string, number]> = async ctx => {
+      const [, limit] = ctx.queryKey
+      const value = limit % 2 && ctx.signal ? 'abort' : `data ${limit}`
+      await sleep(10)
+      return value
+    }
+
+    function Page(props: { limit: number }) {
+      const state = useQuery([key, props.limit], queryFn)
+      states[props.limit] = state
+      return (
+        <div>
+          <h1>Status: {state.status}</h1>
+        </div>
+      )
+    }
+
+    const rendered = renderWithClient(
+      queryClient,
+      <Blink duration={5}>
+        <Page limit={0} />
+        <Page limit={1} />
+        <Page limit={2} />
+        <Page limit={3} />
+      </Blink>
+    )
+
+    await waitFor(() => rendered.getByText('off'))
+    await sleep(10)
+
+    expect(states).toHaveLength(4)
+
+    expect(queryCache.find([key, 0])?.state).toMatchObject({
+      data: 'data 0',
+      status: 'success',
+      dataUpdateCount: 1,
+    })
+
+    if (typeof AbortSignal === 'function') {
+      expect(queryCache.find([key, 1])?.state).toMatchObject({
+        data: undefined,
+        status: 'idle',
+      })
+    } else {
+      expect(queryCache.find([key, 1])?.state).toMatchObject({
+        data: 'data 1',
+        status: 'success',
+        dataUpdateCount: 1,
+      })
+    }
+
+    expect(queryCache.find([key, 2])?.state).toMatchObject({
+      data: 'data 2',
+      status: 'success',
+      dataUpdateCount: 1,
+    })
+
+    if (typeof AbortSignal === 'function') {
+      expect(queryCache.find([key, 3])?.state).toMatchObject({
+        data: undefined,
+        status: 'idle',
+      })
+    } else {
+      expect(queryCache.find([key, 3])?.state).toMatchObject({
+        data: 'data 3',
+        status: 'success',
+        dataUpdateCount: 1,
+      })
+    }
+  })
+
   it('should refetch when quickly switching to a failed query', async () => {
     const key = queryKey()
     const states: UseQueryResult<string>[] = []