Skip to content

Commit

Permalink
support passing an external abortsignal to createAsyncThunk
Browse files Browse the repository at this point in the history
  • Loading branch information
EskiMojo14 committed Feb 24, 2025
1 parent 18ddd7e commit ee00174
Show file tree
Hide file tree
Showing 3 changed files with 92 additions and 16 deletions.
34 changes: 31 additions & 3 deletions packages/toolkit/src/createAsyncThunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,36 +246,53 @@ export type AsyncThunkAction<
unwrap: () => Promise<Returned>
}

export interface AsyncThunkDispatchConfig {
signal?: AbortSignal
}

type AsyncThunkActionCreator<
Returned,
ThunkArg,
ThunkApiConfig extends AsyncThunkConfig,
> = IsAny<
ThunkArg,
// any handling
(arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>,
(
arg: ThunkArg,
config?: AsyncThunkDispatchConfig,
) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>,
// unknown handling
unknown extends ThunkArg
? (arg: ThunkArg) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> // argument not specified or specified as void or undefined
? (
arg: ThunkArg,
config?: AsyncThunkDispatchConfig,
) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> // argument not specified or specified as void or undefined
: [ThunkArg] extends [void] | [undefined]
? () => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> // argument contains void
? (
arg?: undefined,
config?: AsyncThunkDispatchConfig,
) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> // argument contains void
: [void] extends [ThunkArg] // make optional
? (
arg?: ThunkArg,
config?: AsyncThunkDispatchConfig,
) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig> // argument contains undefined
: [undefined] extends [ThunkArg]
? WithStrictNullChecks<
// with strict nullChecks: make optional
(
arg?: ThunkArg,
config?: AsyncThunkDispatchConfig,
) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>,
// without strict null checks this will match everything, so don't make it optional
(
arg: ThunkArg,
config?: AsyncThunkDispatchConfig,
) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>
> // default case: normal argument
: (
arg: ThunkArg,
config?: AsyncThunkDispatchConfig,
) => AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig>
>

Expand Down Expand Up @@ -492,6 +509,8 @@ type CreateAsyncThunk<CurriedThunkApiConfig extends AsyncThunkConfig> =
>
}

const externalAbortMessage = 'External signal was aborted'

export const createAsyncThunk = /* @__PURE__ */ (() => {
function createAsyncThunk<
Returned,
Expand Down Expand Up @@ -575,6 +594,7 @@ export const createAsyncThunk = /* @__PURE__ */ (() => {

function actionCreator(
arg: ThunkArg,
{ signal }: AsyncThunkDispatchConfig = {},
): AsyncThunkAction<Returned, ThunkArg, Required<ThunkApiConfig>> {
return (dispatch, getState, extra) => {
const requestId = options?.idGenerator
Expand All @@ -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<typeof fulfilled | typeof rejected>
try {
Expand Down
18 changes: 13 additions & 5 deletions packages/toolkit/src/tests/createAsyncThunk.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
})
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down
56 changes: 48 additions & 8 deletions packages/toolkit/src/tests/createAsyncThunk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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.',
)
})
})

0 comments on commit ee00174

Please sign in to comment.