From ee00174a6e2c9907aa64d06d79f235aa84f05b92 Mon Sep 17 00:00:00 2001 From: Ben Durrant Date: Mon, 24 Feb 2025 22:51:02 +0000 Subject: [PATCH 1/6] support passing an external abortsignal to createAsyncThunk --- packages/toolkit/src/createAsyncThunk.ts | 34 ++++++++++- .../src/tests/createAsyncThunk.test-d.ts | 18 ++++-- .../src/tests/createAsyncThunk.test.ts | 56 ++++++++++++++++--- 3 files changed, 92 insertions(+), 16 deletions(-) diff --git a/packages/toolkit/src/createAsyncThunk.ts b/packages/toolkit/src/createAsyncThunk.ts index 733003e6d4..4abfce9d13 100644 --- a/packages/toolkit/src/createAsyncThunk.ts +++ b/packages/toolkit/src/createAsyncThunk.ts @@ -246,6 +246,10 @@ export type AsyncThunkAction< unwrap: () => Promise } +export interface AsyncThunkDispatchConfig { + signal?: AbortSignal +} + type AsyncThunkActionCreator< Returned, ThunkArg, @@ -253,29 +257,42 @@ type AsyncThunkActionCreator< > = IsAny< ThunkArg, // any handling - (arg: ThunkArg) => AsyncThunkAction, + ( + arg: ThunkArg, + config?: AsyncThunkDispatchConfig, + ) => AsyncThunkAction, // unknown handling unknown extends ThunkArg - ? (arg: ThunkArg) => AsyncThunkAction // argument not specified or specified as void or undefined + ? ( + arg: ThunkArg, + config?: AsyncThunkDispatchConfig, + ) => AsyncThunkAction // argument not specified or specified as void or undefined : [ThunkArg] extends [void] | [undefined] - ? () => AsyncThunkAction // argument contains void + ? ( + arg?: undefined, + config?: AsyncThunkDispatchConfig, + ) => AsyncThunkAction // argument contains void : [void] extends [ThunkArg] // make optional ? ( arg?: ThunkArg, + config?: AsyncThunkDispatchConfig, ) => AsyncThunkAction // argument contains undefined : [undefined] extends [ThunkArg] ? WithStrictNullChecks< // with strict nullChecks: make optional ( arg?: ThunkArg, + config?: AsyncThunkDispatchConfig, ) => AsyncThunkAction, // without strict null checks this will match everything, so don't make it optional ( arg: ThunkArg, + config?: AsyncThunkDispatchConfig, ) => AsyncThunkAction > // default case: normal argument : ( arg: ThunkArg, + config?: AsyncThunkDispatchConfig, ) => AsyncThunkAction > @@ -492,6 +509,8 @@ type CreateAsyncThunk = > } +const externalAbortMessage = 'External signal was aborted' + export const createAsyncThunk = /* @__PURE__ */ (() => { function createAsyncThunk< Returned, @@ -575,6 +594,7 @@ export const createAsyncThunk = /* @__PURE__ */ (() => { function actionCreator( arg: ThunkArg, + { signal }: AsyncThunkDispatchConfig = {}, ): AsyncThunkAction> { return (dispatch, getState, extra) => { const requestId = options?.idGenerator @@ -590,6 +610,14 @@ export const createAsyncThunk = /* @__PURE__ */ (() => { abortController.abort() } + if (signal) { + if (signal.aborted) { + abort(externalAbortMessage) + } else { + signal.addEventListener('abort', () => abort(externalAbortMessage)) + } + } + const promise = (async function () { let finalAction: ReturnType try { diff --git a/packages/toolkit/src/tests/createAsyncThunk.test-d.ts b/packages/toolkit/src/tests/createAsyncThunk.test-d.ts index a6978d4838..19163f50a0 100644 --- a/packages/toolkit/src/tests/createAsyncThunk.test-d.ts +++ b/packages/toolkit/src/tests/createAsyncThunk.test-d.ts @@ -15,6 +15,7 @@ import { import type { TSVersion } from '@phryneas/ts-version' import type { AxiosError } from 'axios' import apiRequest from 'axios' +import type { AsyncThunkDispatchConfig } from '@internal/createAsyncThunk' const defaultDispatch = (() => {}) as ThunkDispatch<{}, any, UnknownAction> const unknownAction = { type: 'foo' } as UnknownAction @@ -269,7 +270,9 @@ describe('type tests', () => { expectTypeOf(asyncThunk).toMatchTypeOf<() => any>() - expectTypeOf(asyncThunk).parameters.toEqualTypeOf<[]>() + expectTypeOf(asyncThunk).parameters.toEqualTypeOf< + [undefined?, AsyncThunkDispatchConfig?] + >() expectTypeOf(asyncThunk).returns.toBeFunction() }) @@ -279,7 +282,9 @@ describe('type tests', () => { expectTypeOf(asyncThunk).toMatchTypeOf<() => any>() - expectTypeOf(asyncThunk).parameters.toEqualTypeOf<[]>() + expectTypeOf(asyncThunk).parameters.toEqualTypeOf< + [undefined?, AsyncThunkDispatchConfig?] + >() }) test('one argument, specified as void: asyncThunk has no argument', () => { @@ -388,13 +393,14 @@ describe('type tests', () => { expectTypeOf(asyncThunk).toBeCallableWith() - // @ts-expect-error cannot be called with an argument, even if the argument is `undefined` expectTypeOf(asyncThunk).toBeCallableWith(undefined) // cannot be called with an argument expectTypeOf(asyncThunk).parameter(0).not.toBeAny() - expectTypeOf(asyncThunk).parameters.toEqualTypeOf<[]>() + expectTypeOf(asyncThunk).parameters.toEqualTypeOf< + [undefined?, AsyncThunkDispatchConfig?] + >() }) test('two arguments, first specified as void: asyncThunk has no argument', () => { @@ -409,7 +415,9 @@ describe('type tests', () => { // cannot be called with an argument expectTypeOf(asyncThunk).parameter(0).not.toBeAny() - expectTypeOf(asyncThunk).parameters.toEqualTypeOf<[]>() + expectTypeOf(asyncThunk).parameters.toEqualTypeOf< + [undefined?, AsyncThunkDispatchConfig?] + >() }) test('two arguments, first specified as number|undefined: asyncThunk has optional number argument', () => { diff --git a/packages/toolkit/src/tests/createAsyncThunk.test.ts b/packages/toolkit/src/tests/createAsyncThunk.test.ts index ef67002899..ece912d90d 100644 --- a/packages/toolkit/src/tests/createAsyncThunk.test.ts +++ b/packages/toolkit/src/tests/createAsyncThunk.test.ts @@ -879,17 +879,18 @@ test('`condition` will see state changes from a synchronously invoked asyncThunk expect(onStart).toHaveBeenCalledTimes(2) }) +const getNewStore = () => + configureStore({ + reducer(actions: UnknownAction[] = [], action) { + return [...actions, action] + }, + }) + describe('meta', () => { - const getNewStore = () => - configureStore({ - reducer(actions = [], action) { - return [...actions, action] - }, - }) - const store = getNewStore() + let store = getNewStore() beforeEach(() => { - const store = getNewStore() + store = getNewStore() }) test('pendingMeta', () => { @@ -1003,3 +1004,42 @@ describe('meta', () => { expect(result.error).toEqual('serialized!') }) }) + +describe('dispatch config', () => { + let store = getNewStore() + + beforeEach(() => { + store = getNewStore() + }) + test('accepts external signal', async () => { + const asyncThunk = createAsyncThunk('test', async (_: void, { signal }) => { + signal.throwIfAborted() + const { promise, reject } = Promise.withResolvers() + signal.addEventListener('abort', () => reject(signal.reason)) + return promise + }) + + const abortController = new AbortController() + const promise = store.dispatch( + asyncThunk(undefined, { signal: abortController.signal }), + ) + abortController.abort() + await expect(promise.unwrap()).rejects.toThrow( + 'External signal was aborted', + ) + }) + test('handles already aborted external signal', async () => { + const asyncThunk = createAsyncThunk('test', async (_: void, { signal }) => { + signal.throwIfAborted() + const { promise, reject } = Promise.withResolvers() + signal.addEventListener('abort', () => reject(signal.reason)) + return promise + }) + + const signal = AbortSignal.abort() + const promise = store.dispatch(asyncThunk(undefined, { signal })) + await expect(promise.unwrap()).rejects.toThrow( + 'Aborted due to condition callback returning false.', + ) + }) +}) From 16a6b3bde5f7fdeb861a94bce9847e0c89b726b9 Mon Sep 17 00:00:00 2001 From: Ben Durrant Date: Mon, 24 Feb 2025 22:58:04 +0000 Subject: [PATCH 2/6] make our own promiseWithResolvers --- .../toolkit/src/tests/createAsyncThunk.test.ts | 6 +++--- packages/toolkit/src/utils.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/toolkit/src/tests/createAsyncThunk.test.ts b/packages/toolkit/src/tests/createAsyncThunk.test.ts index ece912d90d..3dcda30098 100644 --- a/packages/toolkit/src/tests/createAsyncThunk.test.ts +++ b/packages/toolkit/src/tests/createAsyncThunk.test.ts @@ -1,5 +1,5 @@ import { noop } from '@internal/listenerMiddleware/utils' -import { delay } from '@internal/utils' +import { delay, promiseWithResolvers } from '@internal/utils' import type { CreateAsyncThunkFunction, UnknownAction } from '@reduxjs/toolkit' import { configureStore, @@ -1014,7 +1014,7 @@ describe('dispatch config', () => { test('accepts external signal', async () => { const asyncThunk = createAsyncThunk('test', async (_: void, { signal }) => { signal.throwIfAborted() - const { promise, reject } = Promise.withResolvers() + const { promise, reject } = promiseWithResolvers() signal.addEventListener('abort', () => reject(signal.reason)) return promise }) @@ -1031,7 +1031,7 @@ describe('dispatch config', () => { test('handles already aborted external signal', async () => { const asyncThunk = createAsyncThunk('test', async (_: void, { signal }) => { signal.throwIfAborted() - const { promise, reject } = Promise.withResolvers() + const { promise, reject } = promiseWithResolvers() signal.addEventListener('abort', () => reject(signal.reason)) return promise }) diff --git a/packages/toolkit/src/utils.ts b/packages/toolkit/src/utils.ts index 6607f4b339..1b7955a2e9 100644 --- a/packages/toolkit/src/utils.ts +++ b/packages/toolkit/src/utils.ts @@ -109,3 +109,17 @@ export function getOrInsertComputed( return map.set(key, compute(key)).get(key) as V } + +export function promiseWithResolvers(): { + promise: Promise + resolve: (value: T | PromiseLike) => void + reject: (reason?: any) => void +} { + let resolve: any + let reject: any + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} From 069f5a2017be4ce4a89b754f5b06aa4443e18d1d Mon Sep 17 00:00:00 2001 From: Ben Durrant Date: Mon, 24 Feb 2025 22:59:59 +0000 Subject: [PATCH 3/6] jsdoc --- packages/toolkit/src/createAsyncThunk.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/toolkit/src/createAsyncThunk.ts b/packages/toolkit/src/createAsyncThunk.ts index 4abfce9d13..ac10f326b8 100644 --- a/packages/toolkit/src/createAsyncThunk.ts +++ b/packages/toolkit/src/createAsyncThunk.ts @@ -246,7 +246,13 @@ export type AsyncThunkAction< unwrap: () => Promise } +/** + * Config provided when calling the async thunk action creator. + */ export interface AsyncThunkDispatchConfig { + /** + * An external `AbortSignal` that will be tracked by the internal `AbortSignal`. + */ signal?: AbortSignal } From 7473e9396af3706165002ee51c3dab22f3f3d18a Mon Sep 17 00:00:00 2001 From: Ben Durrant Date: Tue, 25 Feb 2025 10:32:19 +0000 Subject: [PATCH 4/6] add once option --- packages/toolkit/src/createAsyncThunk.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/toolkit/src/createAsyncThunk.ts b/packages/toolkit/src/createAsyncThunk.ts index ac10f326b8..b6d51759fe 100644 --- a/packages/toolkit/src/createAsyncThunk.ts +++ b/packages/toolkit/src/createAsyncThunk.ts @@ -620,7 +620,11 @@ export const createAsyncThunk = /* @__PURE__ */ (() => { if (signal.aborted) { abort(externalAbortMessage) } else { - signal.addEventListener('abort', () => abort(externalAbortMessage)) + signal.addEventListener( + 'abort', + () => abort(externalAbortMessage), + { once: true }, + ) } } From e331ef73b68f2d3b2e8b0803bc29610ef20c480b Mon Sep 17 00:00:00 2001 From: Ben Durrant Date: Tue, 25 Feb 2025 17:01:56 +0000 Subject: [PATCH 5/6] added docs --- docs/api/createAsyncThunk.mdx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/api/createAsyncThunk.mdx b/docs/api/createAsyncThunk.mdx index 40e805028d..b7ae6a684a 100644 --- a/docs/api/createAsyncThunk.mdx +++ b/docs/api/createAsyncThunk.mdx @@ -133,6 +133,18 @@ When dispatched, the thunk will: - if the promise failed and was not handled with `rejectWithValue`, dispatch the `rejected` action with a serialized version of the error value as `action.error` - Return a fulfilled promise containing the final dispatched action (either the `fulfilled` or `rejected` action object) +## Thunk Dispatch Options + +The returned thunk action creator accepts an optional second argument with the following options: + +- `signal`: an optional [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that will be tracked by the internal abort signal (see [Canceling While Running](#canceling-while-running)) + +```ts +const externalController = new AbortController() +dispatch(fetchUserById(123, { signal: externalController.signal })) +externalController.abort() +``` + ## Promise Lifecycle Actions `createAsyncThunk` will generate three Redux action creators using [`createAction`](./createAction.mdx): `pending`, `fulfilled`, and `rejected`. Each lifecycle action creator will be attached to the returned thunk action creator so that your reducer logic can reference the action types and respond to the actions when dispatched. Each action object will contain the current unique `requestId` and `arg` values under `action.meta`. From 602b0cd414d2e0b78968af0e2fe8cbe55e65ad10 Mon Sep 17 00:00:00 2001 From: Ben Durrant Date: Wed, 26 Feb 2025 20:29:56 +0000 Subject: [PATCH 6/6] added no-transpile --- docs/api/createAsyncThunk.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/createAsyncThunk.mdx b/docs/api/createAsyncThunk.mdx index b7ae6a684a..872d146655 100644 --- a/docs/api/createAsyncThunk.mdx +++ b/docs/api/createAsyncThunk.mdx @@ -139,7 +139,7 @@ The returned thunk action creator accepts an optional second argument with the f - `signal`: an optional [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that will be tracked by the internal abort signal (see [Canceling While Running](#canceling-while-running)) -```ts +```ts no-transpile const externalController = new AbortController() dispatch(fetchUserById(123, { signal: externalController.signal })) externalController.abort()