Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions packages/toolkit/src/createAsyncThunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,36 +246,59 @@ export type AsyncThunkAction<
unwrap: () => Promise<Returned>
}

/**
* 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
}

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 +515,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 +600,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 +616,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
58 changes: 49 additions & 9 deletions packages/toolkit/src/tests/createAsyncThunk.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down 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 } = promiseWithResolvers<never>()
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 } = promiseWithResolvers<never>()
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.',
)
})
})
14 changes: 14 additions & 0 deletions packages/toolkit/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,17 @@ export function getOrInsertComputed<K extends object, V>(

return map.set(key, compute(key)).get(key) as V
}

export function promiseWithResolvers<T>(): {
promise: Promise<T>
resolve: (value: T | PromiseLike<T>) => void
reject: (reason?: any) => void
} {
let resolve: any
let reject: any
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}