diff --git a/docs/api/createAsyncThunk.mdx b/docs/api/createAsyncThunk.mdx index fb237c2338..e29ebc9939 100644 --- a/docs/api/createAsyncThunk.mdx +++ b/docs/api/createAsyncThunk.mdx @@ -96,7 +96,7 @@ The logic in the `payloadCreator` function may use any of these values as needed An object with the following optional fields: -- `condition(arg, { getState, extra } ): boolean`: a callback that can be used to skip execution of the payload creator and all action dispatches, if desired. See [Canceling Before Execution](#canceling-before-execution) for a complete description. +- `condition(arg, { getState, extra } ): boolean | Promise`: a callback that can be used to skip execution of the payload creator and all action dispatches, if desired. See [Canceling Before Execution](#canceling-before-execution) for a complete description. - `dispatchConditionRejection`: if `condition()` returns `false`, the default behavior is that no actions will be dispatched at all. If you still want a "rejected" action to be dispatched when the thunk was canceled, set this flag to `true`. - `idGenerator(): string`: a function to use when generating the `requestId` for the request sequence. Defaults to use [nanoid](./otherExports.mdx/#nanoid). - `serializeError(error: unknown) => any` to replace the internal `miniSerializeError` method with your own serialization logic. @@ -357,7 +357,7 @@ const updateUser = createAsyncThunk( ### Canceling Before Execution -If you need to cancel a thunk before the payload creator is called, you may provide a `condition` callback as an option after the payload creator. The callback will receive the thunk argument and an object with `{getState, extra}` as parameters, and use those to decide whether to continue or not. If the execution should be canceled, the `condition` callback should return a literal `false` value: +If you need to cancel a thunk before the payload creator is called, you may provide a `condition` callback as an option after the payload creator. The callback will receive the thunk argument and an object with `{getState, extra}` as parameters, and use those to decide whether to continue or not. If the execution should be canceled, the `condition` callback should return a literal `false` value or a promise that should resolve to `false`. If a promise is returned, the thunk waits for it to get fulfilled before dispatching the `pending` action, otherwise it proceeds with dispatching synchronously. ```js const fetchUserById = createAsyncThunk( diff --git a/packages/toolkit/src/createAsyncThunk.ts b/packages/toolkit/src/createAsyncThunk.ts index e3fafbe765..b7e18d69e9 100644 --- a/packages/toolkit/src/createAsyncThunk.ts +++ b/packages/toolkit/src/createAsyncThunk.ts @@ -286,7 +286,7 @@ export type AsyncThunkOptions< condition?( arg: ThunkArg, api: Pick, 'getState' | 'extra'> - ): boolean | undefined + ): MaybePromise /** * If `condition` returns `false`, the asyncThunk will be skipped. * This option allows you to control whether a `rejected` action with `meta.condition == false` @@ -553,11 +553,11 @@ If you want to use the AbortController to react to \`abort\` events, please cons const promise = (async function () { let finalAction: ReturnType try { - if ( - options && - options.condition && - options.condition(arg, { getState, extra }) === false - ) { + let conditionResult = options?.condition?.(arg, { getState, extra }) + if (isThenable(conditionResult)) { + conditionResult = await conditionResult + } + if (conditionResult === false) { // eslint-disable-next-line no-throw-literal throw { name: 'ConditionError', @@ -678,3 +678,11 @@ export function unwrapResult( type WithStrictNullChecks = undefined extends boolean ? False : True + +function isThenable(value: any): value is PromiseLike { + return ( + value !== null && + typeof value === 'object' && + typeof value.then === 'function' + ) +} diff --git a/packages/toolkit/src/tests/createAsyncThunk.test.ts b/packages/toolkit/src/tests/createAsyncThunk.test.ts index d84dc8c62b..052d402633 100644 --- a/packages/toolkit/src/tests/createAsyncThunk.test.ts +++ b/packages/toolkit/src/tests/createAsyncThunk.test.ts @@ -595,6 +595,32 @@ describe('conditional skipping of asyncThunks', () => { ) }) + test('pending is dispatched synchronously if condition is synchronous', async () => { + const condition = () => true + const asyncThunk = createAsyncThunk('test', payloadCreator, { condition }) + const thunkCallPromise = asyncThunk(arg)(dispatch, getState, extra) + expect(dispatch).toHaveBeenCalledTimes(1) + await thunkCallPromise + expect(dispatch).toHaveBeenCalledTimes(2) + }) + + test('async condition', async () => { + const condition = () => Promise.resolve(false) + const asyncThunk = createAsyncThunk('test', payloadCreator, { condition }) + await asyncThunk(arg)(dispatch, getState, extra) + expect(dispatch).toHaveBeenCalledTimes(0) + }) + + test('async condition with rejected promise', async () => { + const condition = () => Promise.reject() + const asyncThunk = createAsyncThunk('test', payloadCreator, { condition }) + await asyncThunk(arg)(dispatch, getState, extra) + expect(dispatch).toHaveBeenCalledTimes(1) + expect(dispatch).toHaveBeenLastCalledWith( + expect.objectContaining({ type: 'test/rejected' }) + ) + }) + test('rejected action is not dispatched by default', async () => { const asyncThunk = createAsyncThunk('test', payloadCreator, { condition }) await asyncThunk(arg)(dispatch, getState, extra) @@ -644,38 +670,39 @@ describe('conditional skipping of asyncThunks', () => { }) ) }) +}) - test('serializeError implementation', async () => { - function serializeError() { - return 'serialized!' - } - const errorObject = 'something else!' +test('serializeError implementation', async () => { + function serializeError() { + return 'serialized!' + } + const errorObject = 'something else!' - const store = configureStore({ - reducer: (state = [], action) => [...state, action], - }) + const store = configureStore({ + reducer: (state = [], action) => [...state, action], + }) - const asyncThunk = createAsyncThunk< - unknown, - void, - { serializedErrorType: string } - >('test', () => Promise.reject(errorObject), { serializeError }) - const rejected = await store.dispatch(asyncThunk()) - if (!asyncThunk.rejected.match(rejected)) { - throw new Error() - } + const asyncThunk = createAsyncThunk< + unknown, + void, + { serializedErrorType: string } + >('test', () => Promise.reject(errorObject), { serializeError }) + const rejected = await store.dispatch(asyncThunk()) + if (!asyncThunk.rejected.match(rejected)) { + throw new Error() + } - const expectation = { - type: 'test/rejected', - payload: undefined, - error: 'serialized!', - meta: expect.any(Object), - } - expect(rejected).toEqual(expectation) - expect(store.getState()[2]).toEqual(expectation) - expect(rejected.error).not.toEqual(miniSerializeError(errorObject)) - }) + const expectation = { + type: 'test/rejected', + payload: undefined, + error: 'serialized!', + meta: expect.any(Object), + } + expect(rejected).toEqual(expectation) + expect(store.getState()[2]).toEqual(expectation) + expect(rejected.error).not.toEqual(miniSerializeError(errorObject)) }) + describe('unwrapResult', () => { const getState = jest.fn(() => ({})) const dispatch = jest.fn((x: any) => x) @@ -790,7 +817,7 @@ describe('idGenerator option', () => { }) }) -test('`condition` will see state changes from a synchonously invoked asyncThunk', () => { +test('`condition` will see state changes from a synchronously invoked asyncThunk', () => { type State = ReturnType const onStart = jest.fn() const asyncThunk = createAsyncThunk<