Repository: tannerlinsley/react-query (ts)

Issue:
useIsMutating return wrong count of mutating calls
### Describe the bug

useIsMutating return `wrong count` of pending mutation when a failure occur 

### Your minimal, reproducible example

https://codesandbox.io/s/great-zhukovsky-vovpcg?file=/src/useCustomMutations.js

### Steps to reproduce

## 1 - Have  custom useMutation function with callbacks
```ts
export const useCallBackMutation = () => {
  return useMutation(
    async () => {
      await new Promise((_, rej) => setTimeout(() => rej(true), 2000));
      return true;
    },
    {
      mutationKey: CALL_BACK_MUTATION_KEY,
      onError: async (error) => {
        return Promise.reject(error);
      },
      onSuccess: async (data) => {
        return data;
      }
    }
  );
};
```


## 2 - Listen for useIsMutation value
```ts
const value = useIsMutations()
```


## 3 - check number is incorrect when useMutation fails
1 - # instead of 0


### Expected behavior

useIsMutations() should return 0, when there are no mutations pending

### How often does this bug happen?

Every time

### Screenshots or Videos

_No response_

### Platform

- OSX
- Chrome 98

### react-query version

3.34

### TypeScript version

_No response_

### Additional context

_No response_

Pull request description:
fix(mutations): avoid infinite loading states if callbacks return an error
closes #3332 

Patch:
diff --git a/src/core/mutation.ts b/src/core/mutation.ts
index 2db6a609b..3880d75cb 100644
--- a/src/core/mutation.ts
+++ b/src/core/mutation.ts
@@ -160,35 +160,7 @@ export class Mutation<
     return this.execute()
   }
 
-  execute(): Promise<TData> {
-    let data: TData
-
-    const restored = this.state.status === 'loading'
-
-    let promise = Promise.resolve()
-
-    if (!restored) {
-      this.dispatch({ type: 'loading', variables: this.options.variables! })
-      promise = promise
-        .then(() => {
-          // Notify cache callback
-          this.mutationCache.config.onMutate?.(
-            this.state.variables,
-            this as Mutation<unknown, unknown, unknown, unknown>
-          )
-        })
-        .then(() => this.options.onMutate?.(this.state.variables!))
-        .then(context => {
-          if (context !== this.state.context) {
-            this.dispatch({
-              type: 'loading',
-              context,
-              variables: this.state.variables,
-            })
-          }
-        })
-    }
-
+  async execute(): Promise<TData> {
     const executeMutation = () => {
       this.retryer = createRetryer({
         fn: () => {
@@ -214,38 +186,51 @@ export class Mutation<
       return this.retryer.promise
     }
 
-    return promise
-      .then(executeMutation)
-      .then(result => {
-        data = result
+    const restored = this.state.status === 'loading'
+    try {
+      if (!restored) {
+        this.dispatch({ type: 'loading', variables: this.options.variables! })
         // Notify cache callback
-        this.mutationCache.config.onSuccess?.(
-          data,
+        this.mutationCache.config.onMutate?.(
           this.state.variables,
-          this.state.context,
           this as Mutation<unknown, unknown, unknown, unknown>
         )
-      })
-      .then(() =>
-        this.options.onSuccess?.(
-          data,
-          this.state.variables!,
-          this.state.context!
-        )
+        const context = await this.options.onMutate?.(this.state.variables!)
+        if (context !== this.state.context) {
+          this.dispatch({
+            type: 'loading',
+            context,
+            variables: this.state.variables,
+          })
+        }
+      }
+      const data = await executeMutation()
+
+      // Notify cache callback
+      this.mutationCache.config.onSuccess?.(
+        data,
+        this.state.variables,
+        this.state.context,
+        this as Mutation<unknown, unknown, unknown, unknown>
       )
-      .then(() =>
-        this.options.onSettled?.(
-          data,
-          null,
-          this.state.variables!,
-          this.state.context
-        )
+
+      await this.options.onSuccess?.(
+        data,
+        this.state.variables!,
+        this.state.context!
       )
-      .then(() => {
-        this.dispatch({ type: 'success', data })
-        return data
-      })
-      .catch(error => {
+
+      await this.options.onSettled?.(
+        data,
+        null,
+        this.state.variables!,
+        this.state.context
+      )
+
+      this.dispatch({ type: 'success', data })
+      return data
+    } catch (error) {
+      try {
         // Notify cache callback
         this.mutationCache.config.onError?.(
           error,
@@ -258,27 +243,23 @@ export class Mutation<
           this.logger.error(error)
         }
 
-        return Promise.resolve()
-          .then(() =>
-            this.options.onError?.(
-              error,
-              this.state.variables!,
-              this.state.context
-            )
-          )
-          .then(() =>
-            this.options.onSettled?.(
-              undefined,
-              error,
-              this.state.variables!,
-              this.state.context
-            )
-          )
-          .then(() => {
-            this.dispatch({ type: 'error', error })
-            throw error
-          })
-      })
+        await this.options.onError?.(
+          error as TError,
+          this.state.variables!,
+          this.state.context
+        )
+
+        await this.options.onSettled?.(
+          undefined,
+          error as TError,
+          this.state.variables!,
+          this.state.context
+        )
+        throw error
+      } finally {
+        this.dispatch({ type: 'error', error: error as TError })
+      }
+    }
   }
 
   private dispatch(action: Action<TData, TError, TVariables, TContext>): void {


Test patch:
diff --git a/src/reactjs/tests/useMutation.test.tsx b/src/reactjs/tests/useMutation.test.tsx
index 8bf5e06b3..24b9f41c2 100644
--- a/src/reactjs/tests/useMutation.test.tsx
+++ b/src/reactjs/tests/useMutation.test.tsx
@@ -855,4 +855,115 @@ describe('useMutation', () => {
       undefined
     )
   })
+
+  test('should go to error state if onSuccess callback errors', async () => {
+    const error = new Error('error from onSuccess')
+    const onError = jest.fn()
+
+    function Page() {
+      const mutation = useMutation(
+        async (_text: string) => {
+          await sleep(10)
+          return 'result'
+        },
+        {
+          onSuccess: () => Promise.reject(error),
+          onError,
+        }
+      )
+
+      return (
+        <div>
+          <button onClick={() => mutation.mutate('todo')}>mutate</button>
+          <div>status: {mutation.status}</div>
+        </div>
+      )
+    }
+
+    const rendered = renderWithClient(queryClient, <Page />)
+
+    await rendered.findByText('status: idle')
+
+    rendered.getByRole('button', { name: /mutate/i }).click()
+
+    await rendered.findByText('status: error')
+
+    expect(onError).toHaveBeenCalledWith(error, 'todo', undefined)
+  })
+
+  test('should go to error state if onError callback errors', async () => {
+    const error = new Error('error from onError')
+    const mutateFnError = new Error('mutateFnError')
+
+    function Page() {
+      const mutation = useMutation(
+        async (_text: string) => {
+          await sleep(10)
+          throw mutateFnError
+        },
+        {
+          onError: () => Promise.reject(error),
+        }
+      )
+
+      return (
+        <div>
+          <button onClick={() => mutation.mutate('todo')}>mutate</button>
+          <div>
+            error:{' '}
+            {mutation.error instanceof Error ? mutation.error.message : 'null'},
+            status: {mutation.status}
+          </div>
+        </div>
+      )
+    }
+
+    const rendered = renderWithClient(queryClient, <Page />)
+
+    await rendered.findByText('error: null, status: idle')
+
+    rendered.getByRole('button', { name: /mutate/i }).click()
+
+    await rendered.findByText('error: mutateFnError, status: error')
+  })
+
+  test('should go to error state if onSettled callback errors', async () => {
+    const error = new Error('error from onSettled')
+    const mutateFnError = new Error('mutateFnError')
+    const onError = jest.fn()
+
+    function Page() {
+      const mutation = useMutation(
+        async (_text: string) => {
+          await sleep(10)
+          throw mutateFnError
+        },
+        {
+          onSettled: () => Promise.reject(error),
+          onError,
+        }
+      )
+
+      return (
+        <div>
+          <button onClick={() => mutation.mutate('todo')}>mutate</button>
+          <div>
+            error:{' '}
+            {mutation.error instanceof Error ? mutation.error.message : 'null'},
+            status: {mutation.status}
+          </div>
+        </div>
+      )
+    }
+
+    const rendered = renderWithClient(queryClient, <Page />)
+
+    await rendered.findByText('error: null, status: idle')
+
+    rendered.getByRole('button', { name: /mutate/i }).click()
+
+    await rendered.findByText('error: mutateFnError, status: error')
+
+    expect(onError).toHaveBeenCalledWith(mutateFnError, 'todo', undefined)
+  })
 })