Repository: reduxjs/redux-toolkit

Problem statement:
Async `condition` in `createAsyncThunk`
I need my thunk to detect that another instance of the same operation is already running and to fulfill the promise (without dispatching any actions) only after that other instance is done. The code that dispatches the thunk doesn't care who started the operation, it just needs it to complete. As I have more than one such place in my code, I'd prefer to encapsulate this logic into the thunk, which would be possible if `options.condition` could return a promise. That looks like a simple change. I can give a shot at a PR.


Hints:
Unfortunately, that could be seen as a breaking change. 
Right now, `pending` is dispatched synchronously. 

That guarantee would get lost. It *might* be possible to work around it somehow so it gets only async if the condition returns a promise, but I fear that would turn `cAT` in an even more unreadable monstrosity. 


If you can get to it without too many changes and while exactly keeping the current behaviour, it might be worth a shot, but that might really get tricky.

Patch:
diff --git a/packages/toolkit/src/createAsyncThunk.ts b/packages/toolkit/src/createAsyncThunk.ts
--- a/packages/toolkit/src/createAsyncThunk.ts
+++ b/packages/toolkit/src/createAsyncThunk.ts
@@ -286,7 +286,7 @@ export type AsyncThunkOptions<
   condition?(
     arg: ThunkArg,
     api: Pick<GetThunkAPI<ThunkApiConfig>, 'getState' | 'extra'>
-  ): boolean | undefined
+  ): MaybePromise<boolean | undefined>
   /**
    * 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<typeof fulfilled | typeof rejected>
         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<R extends UnwrappableAction>(
 type WithStrictNullChecks<True, False> = undefined extends boolean
   ? False
   : True
+
+function isThenable(value: any): value is PromiseLike<any> {
+  return (
+    value !== null &&
+    typeof value === 'object' &&
+    typeof value.then === 'function'
+  )
+}


Test patch:
diff --git a/packages/toolkit/src/tests/createAsyncThunk.test.ts b/packages/toolkit/src/tests/createAsyncThunk.test.ts
--- 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<typeof store.getState>
   const onStart = jest.fn()
   const asyncThunk = createAsyncThunk<