Skip to content

throw in onError fails silently and stops flow, while Promise.reject() works as expected and shows Uncaught Error in console #6480

@swingthrough

Description

@swingthrough

Describe the bug

Issue

Throwing from inside onError fails silently (no hint in the console about Uncaught Error).
However, using Promise.reject works as expected and the error can be seen in the console - "Uncaught error...".
This is the case for all three onError definitions:

  1. mutationCache
  2. useMutation
  3. mutate

And the second thing - throwing from inside onError defined in mutationCache also prevents onError defined in useMutation from running at all.
However, onError defined on mutate is called always:

  • so no matter if we rejected or thrown from mutationCache
  • no matter if we rejected or thrown from useMutation
    onError in mutate will be called anyway.

Here's a codesandbox demo.

Your minimal, reproducible example

https://codesandbox.io/p/sandbox/sweet-villani-clpw7d?file=%2Fsrc%2FApp.tsx%3A52%2C8

Steps to reproduce

Code to reproduce the issue:

queryClient:

export const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onError: (error) => {
      console.log("[mutationCache][onError] triggered with error: ", error);

      try {
        // do something that might throw an error
        throw new Error("(ERROR A: thrown from mutationCache onError)");
      } catch (e) {
        console.log("[mutationCache][onError] caught e: ", e);
        /**
         * Throwing here (or simply not wrapping previous call in try/catch) causes it
         * to stop here - so useMutation::onError in App.tsx is not even called
         * and there is no hint in console apart from the explicit console.log(s) above
         */
        throw e;
        /**
         * Instead of throwing, rejecting like this makes useMutation::onError in App.tsx run as expected,
         * and after that there is clear "Uncaught (in promise) Error: (ERROR A: thrown from mutationCache onError)"
         * shown in the console
         */
        // Promise.reject(e);
      }
    },
  }),
});

useMutation:

const { mutate } = useMutation({
    mutationFn: async () => {
      throw new Error("DUMMY POST REQUEST FAILED");
    },
    onError: (error) => {
      /**
       * If in index.tsx in MutationCache::onError we throw, we don't even get here,
       * and there is no hint about uncaught error, only the explicit console.log(s) that are there.
       *
       * But if we use Promise.reject there, we do get here, so let's say we've done that
       * and what comes next is the following:
       */

      console.log("[useMutation][onError] triggered with error: ", error);
      try {
        // do something that might throw an error
        throw new Error("(ERROR B: thrown from useMutation onError)");
      } catch (e) {
        console.log("[useMutation][onError] caught e: ", e);
        /**
         * Throwing here (or simply not wrapping previous call in try/catch) means
         * that the only hint about ERROR B being thrown is the explicit console.log above.
         * Then after those, the only thing we will see is:
         * "Uncaught (in promise) Error: (ERROR A: thrown from mutationCache onError)"
         * which comes from the mutationCache::onError in index.tsx
         */
        throw e;
        /**
         * However, instead of throwing, if we reject, in the console we will see:
         * "Uncaught (in promise) Error: (ERROR A: thrown from mutationCache onError)"
         * followed by:
         * "Uncaught (in promise) Error: (ERROR B: thrown from useMutation onError)"
         */
        // Promise.reject(e);
      }
    },
  });

mutate:

mutate(undefined, {
  onError: (error) => {
    /**
     * Now for this onError inside mutate call, this one is called no matter
     * if we throw or reject inside the mutationCache::onError.
     * So if we reject in mutationCache, the callbacks are called in the following order:
     *   1. mutationCache::onError
     *   2. useMutation::onError
     *   3. mutate::onError
     *
     * But if we throw in mutationCache, it's like this:
     *   1. mutationCache::onError
     *   2. mutate::onError
     * So useMutation::onError is not called, but this one still is
     */
    console.log("[mutate][onError] triggered with error: ", error);
    try {
      // do something that might throw an error
      throw new Error("(ERROR C: thrown from useMutation onError)");
    } catch (e) {
      console.log("[mutate][onError] caught e: ", e);
  
      /**
       * But here it is the same as for the other cases,
       * if we throw (or don't wrap previous call in try/catch), we won't see a hint about ERROR C apart from the console.log above
       */
      throw e;
  
      /**
       * If we reject, we can see in the console:
       * "Uncaught (in promise) Error: (ERROR C: thrown from useMutation onError)"
       */
      // Promise.reject(e);
    }
  },
  });

Expected behavior

I'd expect to see the Uncaught error in the console even if I use throw.
The case for me where I discovered this issue was that I was calling Object.keys(someObject.errors) inside onError in mutationCache and using a toast to show the error.
I also had some logic in onError defined in useMutation, setting an error for a form.
In one case the errors property was undefined, which caused Object.keys to throw before the toast could show anything - onError in useMutation was not called so no form error was set. So there was no hint at all about an error occurring.

So the only safe approach here seems to be wrapping the logic in onError in mutationCache in try/catch and Promise.reject the error from the catch clause - but this seems like an extra step that should not be necessary.
And having no hint about an error occurring seems like a bug.

How often does this bug happen?

Every time

Screenshots or Videos

No response

Platform

  • OS: macOS
  • Browser: Chrome

Tanstack Query adapter

react-query

TanStack Query version

v5.12.2

TypeScript version

No response

Additional context

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions