diff --git a/packages/toolkit/src/createAsyncThunk.ts b/packages/toolkit/src/createAsyncThunk.ts index b6d51759fe..899c993c57 100644 --- a/packages/toolkit/src/createAsyncThunk.ts +++ b/packages/toolkit/src/createAsyncThunk.ts @@ -651,7 +651,9 @@ export const createAsyncThunk = /* @__PURE__ */ (() => { message: abortReason || 'Aborted', }) } - abortController.signal.addEventListener('abort', abortHandler) + abortController.signal.addEventListener('abort', abortHandler, { + once: true, + }) }) dispatch( pending( diff --git a/packages/toolkit/src/listenerMiddleware/index.ts b/packages/toolkit/src/listenerMiddleware/index.ts index 2b7c3e0c51..7f9512ebe2 100644 --- a/packages/toolkit/src/listenerMiddleware/index.ts +++ b/packages/toolkit/src/listenerMiddleware/index.ts @@ -19,7 +19,6 @@ import { validateActive, } from './task' import type { - AbortSignalWithReason, AddListenerOverloads, AnyListenerPredicate, CreateListenerMiddlewareOptions, @@ -41,7 +40,6 @@ import type { UnsubscribeListenerOptions, } from './types' import { - abortControllerWithReason, addAbortSignalListener, assertFunction, catchRejection, @@ -82,12 +80,12 @@ const INTERNAL_NIL_TOKEN = {} as const const alm = 'listenerMiddleware' as const const createFork = ( - parentAbortSignal: AbortSignalWithReason, + parentAbortSignal: AbortSignal, parentBlockingPromises: Promise[], ) => { const linkControllers = (controller: AbortController) => addAbortSignalListener(parentAbortSignal, () => - abortControllerWithReason(controller, parentAbortSignal.reason), + controller.abort(parentAbortSignal.reason), ) return ( @@ -111,7 +109,7 @@ const createFork = ( validateActive(childAbortController.signal) return result }, - () => abortControllerWithReason(childAbortController, taskCompleted), + () => childAbortController.abort(taskCompleted), ) if (opts?.autoJoin) { @@ -121,7 +119,7 @@ const createFork = ( return { result: createPause>(parentAbortSignal)(result), cancel() { - abortControllerWithReason(childAbortController, taskCancelled) + childAbortController.abort(taskCancelled) }, } } @@ -256,7 +254,7 @@ const cancelActiveListeners = ( entry: ListenerEntry>, ) => { entry.pending.forEach((controller) => { - abortControllerWithReason(controller, listenerCancelled) + controller.abort(listenerCancelled) }) } @@ -444,16 +442,13 @@ export const createListenerMiddleware = < cancelActiveListeners: () => { entry.pending.forEach((controller, _, set) => { if (controller !== internalTaskController) { - abortControllerWithReason(controller, listenerCancelled) + controller.abort(listenerCancelled) set.delete(controller) } }) }, cancel: () => { - abortControllerWithReason( - internalTaskController, - listenerCancelled, - ) + internalTaskController.abort(listenerCancelled) entry.pending.delete(internalTaskController) }, throwIfCancelled: () => { @@ -471,7 +466,7 @@ export const createListenerMiddleware = < } finally { await Promise.all(autoJoinPromises) - abortControllerWithReason(internalTaskController, listenerCompleted) // Notify that the task has completed + internalTaskController.abort(listenerCompleted) // Notify that the task has completed untrackExecutingListener(entry) entry.pending.delete(internalTaskController) } diff --git a/packages/toolkit/src/listenerMiddleware/task.ts b/packages/toolkit/src/listenerMiddleware/task.ts index a2fffce0d9..44c2b489df 100644 --- a/packages/toolkit/src/listenerMiddleware/task.ts +++ b/packages/toolkit/src/listenerMiddleware/task.ts @@ -1,17 +1,16 @@ import { TaskAbortError } from './exceptions' -import type { AbortSignalWithReason, TaskResult } from './types' +import type { TaskResult } from './types' import { addAbortSignalListener, catchRejection, noop } from './utils' /** * Synchronously raises {@link TaskAbortError} if the task tied to the input `signal` has been cancelled. * @param signal - * @param reason * @see {TaskAbortError} + * @throws {TaskAbortError} if the task tied to the input `signal` has been cancelled. */ export const validateActive = (signal: AbortSignal): void => { if (signal.aborted) { - const { reason } = signal as AbortSignalWithReason - throw new TaskAbortError(reason) + throw new TaskAbortError(signal.reason) } } @@ -21,7 +20,7 @@ export const validateActive = (signal: AbortSignal): void => { * https://github.com/nodejs/node/issues/17469#issuecomment-349794909 */ export function raceWithSignal( - signal: AbortSignalWithReason, + signal: AbortSignal, promise: Promise, ): Promise { let cleanup = noop diff --git a/packages/toolkit/src/listenerMiddleware/tests/fork.test.ts b/packages/toolkit/src/listenerMiddleware/tests/fork.test.ts index cbb67f6348..7172e5c13d 100644 --- a/packages/toolkit/src/listenerMiddleware/tests/fork.test.ts +++ b/packages/toolkit/src/listenerMiddleware/tests/fork.test.ts @@ -2,11 +2,7 @@ import type { EnhancedStore } from '@reduxjs/toolkit' import { configureStore, createSlice, createAction } from '@reduxjs/toolkit' import type { PayloadAction } from '@reduxjs/toolkit' -import type { - AbortSignalWithReason, - ForkedTaskExecutor, - TaskResult, -} from '../types' +import type { ForkedTaskExecutor, TaskResult } from '../types' import { createListenerMiddleware, TaskAbortError } from '../index' import { listenerCancelled, @@ -382,9 +378,7 @@ describe('fork', () => { listenerApi.fork( async (forkApi) => { forkApi.signal.addEventListener('abort', () => { - deferredResult.resolve( - (forkApi.signal as AbortSignalWithReason).reason, - ) + deferredResult.resolve(forkApi.signal.reason) }) await forkApi.delay(10) diff --git a/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts b/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts index 2b3980ad94..b9be630d5b 100644 --- a/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts +++ b/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts @@ -2,10 +2,7 @@ import { listenerCancelled, listenerCompleted, } from '@internal/listenerMiddleware/exceptions' -import type { - AbortSignalWithReason, - AddListenerOverloads, -} from '@internal/listenerMiddleware/types' +import type { AddListenerOverloads } from '@internal/listenerMiddleware/types' import { noop } from '@internal/listenerMiddleware/utils' import type { Action, @@ -606,7 +603,7 @@ describe('createListenerMiddleware', () => { signal.addEventListener( 'abort', () => { - payload.resolve((signal as AbortSignalWithReason).reason) + payload.resolve(signal.reason) }, { once: true }, ) @@ -636,7 +633,7 @@ describe('createListenerMiddleware', () => { signal.addEventListener( 'abort', () => { - payload.resolve((signal as AbortSignalWithReason).reason) + payload.resolve(signal.reason) }, { once: true }, ) diff --git a/packages/toolkit/src/listenerMiddleware/types.ts b/packages/toolkit/src/listenerMiddleware/types.ts index 7e6f6c2783..74e64edfb3 100644 --- a/packages/toolkit/src/listenerMiddleware/types.ts +++ b/packages/toolkit/src/listenerMiddleware/types.ts @@ -10,12 +10,6 @@ import type { BaseActionCreator, PayloadAction } from '../createAction' import type { TypedActionCreator } from '../mapBuilders' import type { TaskAbortError } from './exceptions' -/** - * @internal - * At the time of writing `lib.dom.ts` does not provide `abortSignal.reason`. - */ -export type AbortSignalWithReason = AbortSignal & { reason?: T } - /** * Types copied from RTK */ diff --git a/packages/toolkit/src/listenerMiddleware/utils.ts b/packages/toolkit/src/listenerMiddleware/utils.ts index dd9480eb1f..f20890fcba 100644 --- a/packages/toolkit/src/listenerMiddleware/utils.ts +++ b/packages/toolkit/src/listenerMiddleware/utils.ts @@ -1,5 +1,3 @@ -import type { AbortSignalWithReason } from './types' - export const assertFunction: ( func: unknown, expected: string, @@ -30,41 +28,3 @@ export const addAbortSignalListener = ( abortSignal.addEventListener('abort', callback, { once: true }) return () => abortSignal.removeEventListener('abort', callback) } - -/** - * Calls `abortController.abort(reason)` and patches `signal.reason`. - * if it is not supported. - * - * At the time of writing `signal.reason` is available in FF chrome, edge node 17 and deno. - * @param abortController - * @param reason - * @returns - * @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/reason - */ -export const abortControllerWithReason = ( - abortController: AbortController, - reason: T, -): void => { - type Consumer = (val: T) => void - - const signal = abortController.signal as AbortSignalWithReason - - if (signal.aborted) { - return - } - - // Patch `reason` if necessary. - // - We use defineProperty here because reason is a getter of `AbortSignal.__proto__`. - // - We need to patch 'reason' before calling `.abort()` because listeners to the 'abort' - // event are are notified immediately. - if (!('reason' in signal)) { - Object.defineProperty(signal, 'reason', { - enumerable: true, - value: reason, - configurable: true, - writable: true, - }) - } - - ;(abortController.abort as Consumer)(reason) -} diff --git a/packages/toolkit/src/query/fetchBaseQuery.ts b/packages/toolkit/src/query/fetchBaseQuery.ts index a3792a8416..cf5d3c9334 100644 --- a/packages/toolkit/src/query/fetchBaseQuery.ts +++ b/packages/toolkit/src/query/fetchBaseQuery.ts @@ -2,6 +2,7 @@ import { joinUrls } from './utils' import { isPlainObject } from './core/rtkImports' import type { BaseQueryApi, BaseQueryFn } from './baseQueryTypes' import type { MaybePromise, Override } from './tsHelpers' +import { anySignal, timeoutSignal } from './utils/signals' export type ResponseHandler = | 'content-type' @@ -233,17 +234,11 @@ export function fetchBaseQuery({ ...rest } = typeof arg == 'string' ? { url: arg } : arg - let abortController: AbortController | undefined, - signal = api.signal - if (timeout) { - abortController = new AbortController() - api.signal.addEventListener('abort', abortController.abort) - signal = abortController.signal - } - let config: RequestInit = { ...baseFetchOptions, - signal, + signal: timeout + ? anySignal(api.signal, timeoutSignal(timeout)) + : api.signal, ...rest, } @@ -303,30 +298,20 @@ export function fetchBaseQuery({ const requestClone = new Request(url, config) meta = { request: requestClone } - let response, - timedOut = false, - timeoutId = - abortController && - setTimeout(() => { - timedOut = true - abortController!.abort() - }, timeout) + let response try { response = await fetchFn(request) } catch (e) { return { error: { - status: timedOut ? 'TIMEOUT_ERROR' : 'FETCH_ERROR', + status: + e instanceof DOMException && e.name === 'TimeoutError' + ? 'TIMEOUT_ERROR' + : 'FETCH_ERROR', error: String(e), }, meta, } - } finally { - if (timeoutId) clearTimeout(timeoutId) - abortController?.signal.removeEventListener( - 'abort', - abortController.abort, - ) } const responseClone = response.clone() diff --git a/packages/toolkit/src/query/tests/createApi.test.ts b/packages/toolkit/src/query/tests/createApi.test.ts index e77155b44a..6e0934213c 100644 --- a/packages/toolkit/src/query/tests/createApi.test.ts +++ b/packages/toolkit/src/query/tests/createApi.test.ts @@ -1197,7 +1197,7 @@ describe('timeout behavior', () => { expect(result?.error).toEqual({ status: 'TIMEOUT_ERROR', - error: expect.stringMatching(/^AbortError:/), + error: expect.stringMatching(/^TimeoutError/), }) }) }) diff --git a/packages/toolkit/src/query/tests/fetchBaseQuery.test.tsx b/packages/toolkit/src/query/tests/fetchBaseQuery.test.tsx index 19a45a88c7..1b48e4badc 100644 --- a/packages/toolkit/src/query/tests/fetchBaseQuery.test.tsx +++ b/packages/toolkit/src/query/tests/fetchBaseQuery.test.tsx @@ -3,7 +3,6 @@ import type { FetchArgs } from '@reduxjs/toolkit/query' import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query' import { headersToObject } from 'headers-polyfill' import { HttpResponse, delay, http } from 'msw' -// @ts-ignore import nodeFetch from 'node-fetch' import queryString from 'query-string' import { vi } from 'vitest' @@ -19,12 +18,8 @@ const defaultHeaders: Record = { const baseUrl = 'https://example.com' -// @ts-ignore -const fetchFn = vi.fn, any[]>(nodeFetch) - const baseQuery = fetchBaseQuery({ baseUrl, - fetchFn: fetchFn as any, prepareHeaders: (headers, { getState }) => { const { token } = (getState() as RootState).auth @@ -127,9 +122,14 @@ describe('fetchBaseQuery', () => { }) it('should handle a connection loss semi-gracefully', async () => { - fetchFn.mockRejectedValueOnce(new TypeError('Failed to fetch')) + const fetchFn = vi + .fn() + .mockRejectedValueOnce(new TypeError('Failed to fetch')) - const req = baseQuery('/success', commonBaseQueryApi, {}) + const req = fetchBaseQuery({ + baseUrl, + fetchFn, + })('/success', commonBaseQueryApi, {}) expect(req).toBeInstanceOf(Promise) const res = await req expect(res).toBeInstanceOf(Object) @@ -419,7 +419,6 @@ describe('fetchBaseQuery', () => { it('supports a custom jsonContentType', async () => { const baseQuery = fetchBaseQuery({ baseUrl, - fetchFn: fetchFn as any, jsonContentType: 'application/vnd.api+json', }) @@ -459,7 +458,6 @@ describe('fetchBaseQuery', () => { // Use jsonReplacer const baseQueryWithReplacer = fetchBaseQuery({ baseUrl, - fetchFn: fetchFn as any, jsonReplacer: (key, value) => value instanceof Set ? [...value] : value, }) @@ -546,7 +544,6 @@ describe('fetchBaseQuery', () => { it('should support a paramsSerializer', async () => { const baseQuery = fetchBaseQuery({ baseUrl, - fetchFn: fetchFn as any, paramsSerializer: (params: Record) => queryString.stringify(params, { arrayFormat: 'bracket' }), }) @@ -591,7 +588,6 @@ describe('fetchBaseQuery', () => { } const baseQuery = fetchBaseQuery({ baseUrl, - fetchFn: fetchFn as any, isJsonContentType: (headers) => [ 'application/vnd.api+json', @@ -805,7 +801,6 @@ describe('fetchBaseQuery', () => { const baseQuery = fetchBaseQuery({ baseUrl, - fetchFn: fetchFn as any, prepareHeaders: ( headers, { getState, arg, extra, endpoint, type, forced }, @@ -974,7 +969,6 @@ describe('fetchBaseQuery', () => { const globalizedBaseQuery = fetchBaseQuery({ baseUrl, - fetchFn: fetchFn as any, responseHandler: 'text', }) @@ -1003,7 +997,6 @@ describe('fetchBaseQuery', () => { const globalizedBaseQuery = fetchBaseQuery({ baseUrl, - fetchFn: fetchFn as any, responseHandler: 'content-type', }) @@ -1031,7 +1024,6 @@ describe('fetchBaseQuery', () => { const globalizedBaseQuery = fetchBaseQuery({ baseUrl, - fetchFn: fetchFn as any, responseHandler: 'content-type', }) @@ -1059,7 +1051,6 @@ describe('fetchBaseQuery', () => { const globalizedBaseQuery = fetchBaseQuery({ baseUrl, - fetchFn: fetchFn as any, responseHandler: 'content-type', }) @@ -1084,7 +1075,6 @@ describe('fetchBaseQuery', () => { const globalizedBaseQuery = fetchBaseQuery({ baseUrl, - fetchFn: fetchFn as any, responseHandler: 'content-type', }) @@ -1113,7 +1103,6 @@ describe('fetchBaseQuery', () => { const globalizedBaseQuery = fetchBaseQuery({ baseUrl, - fetchFn: fetchFn as any, responseHandler: 'content-type', }) @@ -1135,7 +1124,6 @@ describe('fetchBaseQuery', () => { test('Global validateStatus', async () => { const globalizedBaseQuery = fetchBaseQuery({ baseUrl, - fetchFn: fetchFn as any, validateStatus: (response, body) => response.status === 200 && body.success === false ? false : true, }) @@ -1180,7 +1168,6 @@ describe('fetchBaseQuery', () => { const globalizedBaseQuery = fetchBaseQuery({ baseUrl, - fetchFn: fetchFn as any, timeout: 200, }) @@ -1192,7 +1179,7 @@ describe('fetchBaseQuery', () => { expect(result?.error).toEqual({ status: 'TIMEOUT_ERROR', - error: expect.stringMatching(/^AbortError:/), + error: expect.stringMatching(/^TimeoutError/), }) }) }) @@ -1266,7 +1253,6 @@ describe('FormData', () => { // This test covers the exact scenario from issue #4669 const baseQueryWithJsonDefault = fetchBaseQuery({ baseUrl, - fetchFn: fetchFn as any, prepareHeaders: (headers) => { // Set default Content-Type for all requests headers.set('Content-Type', 'application/json') @@ -1301,7 +1287,6 @@ describe('FormData', () => { // This tests the workaround solution from the issue comments const baseQueryWithConditionalHeader = fetchBaseQuery({ baseUrl, - fetchFn: fetchFn as any, prepareHeaders: (headers, { arg }) => { // Check if body is FormData and skip setting Content-Type if ((arg as FetchArgs).body instanceof FormData) { @@ -1336,7 +1321,6 @@ describe('FormData', () => { // This tests the fetch API quirk mentioned in the issue const baseQueryWithJsonDefault = fetchBaseQuery({ baseUrl, - fetchFn: fetchFn as any, prepareHeaders: (headers) => { headers.set('Content-Type', 'application/json') return headers @@ -1371,7 +1355,6 @@ describe('FormData', () => { // Verify that the workaround doesn't break normal JSON requests const baseQueryWithConditionalHeader = fetchBaseQuery({ baseUrl, - fetchFn: fetchFn as any, prepareHeaders: (headers, { arg }) => { if (!((arg as FetchArgs).body instanceof FormData)) { headers.set('Content-Type', 'application/json') @@ -1423,7 +1406,6 @@ describe('Accept header handling', () => { // Create a baseQuery with text as the global responseHandler const textBaseQuery = fetchBaseQuery({ baseUrl, - fetchFn: fetchFn as any, responseHandler: 'text', }) @@ -1458,7 +1440,6 @@ describe('Accept header handling', () => { test('does not override Accept header set in prepareHeaders', async () => { const customBaseQuery = fetchBaseQuery({ baseUrl, - fetchFn: fetchFn as any, prepareHeaders: (headers) => { headers.set('Accept', 'application/vnd.api+json') return headers @@ -1494,7 +1475,6 @@ describe('Accept header handling', () => { test('respects global responseHandler for Accept header', async () => { const textBaseQuery = fetchBaseQuery({ baseUrl, - fetchFn: fetchFn as any, responseHandler: 'text', }) @@ -1559,7 +1539,7 @@ describe('timeout', () => { expect(result?.error).toEqual({ status: 'TIMEOUT_ERROR', - error: expect.stringMatching(/^AbortError:/), + error: expect.stringMatching(/^TimeoutError/), }) }) }) diff --git a/packages/toolkit/src/query/utils/signals.ts b/packages/toolkit/src/query/utils/signals.ts new file mode 100644 index 0000000000..1fb5ca57f0 --- /dev/null +++ b/packages/toolkit/src/query/utils/signals.ts @@ -0,0 +1,27 @@ +// AbortSignal.timeout() is currently baseline 2024 +export const timeoutSignal = (milliseconds: number) => { + const abortController = new AbortController() + setTimeout( + () => abortController.abort(new DOMException('', 'TimeoutError')), + milliseconds, + ) + return abortController.signal +} + +// AbortSignal.any() is currently baseline 2024 +export const anySignal = (...signals: AbortSignal[]) => { + // if any are already aborted, return an already aborted signal + for (const signal of signals) + if (signal.aborted) return AbortSignal.abort(signal.reason) + + // otherwise, create a new signal that aborts when any of the given signals abort + const abortController = new AbortController() + for (const signal of signals) { + signal.addEventListener( + 'abort', + () => abortController.abort(signal.reason), + { signal: abortController.signal, once: true }, + ) + } + return abortController.signal +}