reduxjs / redux-toolkit

The official, opinionated, batteries-included toolset for efficient Redux development
https://redux-toolkit.js.org
MIT License
10.76k stars 1.18k forks source link

catching errors in onQueryStarted remain unhandled in ui #4690

Closed anthonyss09 closed 2 weeks ago

anthonyss09 commented 3 weeks ago

I have apiSlice where I create basequery with qraphqlbasequery. In authSlice.ts I inject endpoints. At the endpoint createCustomerToken I use onQueryStarted where I await queryFulfilled in try/catch. The catch block catches server response errors and I display an alert to client which all works. The problem is I still see the nextjs red error circle on UI & the error is still logged as unhandled. I've added errorLogger.ts middleware to the store as an added precaution but I cannot seem to handle errors coming from server. Relevant code below. Thanks.

apiSlice.ts

import { createApi } from "@reduxjs/toolkit/query/react";
import { graphqlRequestBaseQuery } from "@rtk-query/graphql-request-base-query";
import { GraphQLClient } from "graphql-request";

export const client: any = new GraphQLClient(
  `https://${process.env.NEXT_PUBLIC_SHOP_NAME}.myshopify.com/api/${process.env.NEXT_PUBLIC_VERSION}/graphql.json`
);

client.setHeader(
  "X-Shopify-Storefront-Access-Token",
  process.env.NEXT_PUBLIC_SHOPIFY_ACCESS_TOKEN
    ? process.env.NEXT_PUBLIC_SHOPIFY_ACCESS_TOKEN
    : ""
);

export const apiSlice = createApi({
  reducerPath: "api",
  baseQuery: graphqlRequestBaseQuery({
    client,

    prepareHeaders: (headers, { getState }) => {
      headers.set("Accept", "application/json");
      headers.set("Content-Type", "application/json");
      return headers;
    },
  }),

  tagTypes: ["Cart", "Customer"],
  endpoints: (build) => ({}),
});

authSlice.ts

import { createSlice } from "@reduxjs/toolkit";
import type { RootState } from "../../store";
import { apiSlice } from "../api/apiSlice";
import {
  createTokenArgs,
} from "./types";
import { showAlert, clearAlert } from "../alerts/alertsSlice";

let customerAccessToken;
if (typeof localStorage !== "undefined") {
  const localToken = localStorage.getItem("blissCustomerAccessToken");
  if (localToken !== null) {
    customerAccessToken = JSON.parse(localToken);
  } else {
    customerAccessToken = null;
  }
} else {
  customerAccessToken = null;
}
const initialState = {
  customerAccessToken,
  customer: { firstName: "", id: null },
};

const authSlice = createSlice({
  name: "auth",
  initialState,

  reducers: {
    setCustomerAccessToken(state, action) {
      state.customerAccessToken = action.payload;
    },
    setCustomerData(state, action) {
      state.customer = action.payload;
    },
    logoutCustomer(state, action) {
      state.customer = { firstName: "", id: null };
      state.customerAccessToken = null;
    },
  },
});

export const extendedApi = apiSlice.injectEndpoints({
  endpoints: (build: any) => ({
    createCustomerToken: build.mutation({
      query: (arg: createTokenArgs) => ({
        document: `
          mutation {
            customerAccessTokenCreate(input: {email: "${arg.email}", password: "${arg.password}"})
            {
              customerAccessToken {
                accessToken
              }
              customerUserErrors {
                message
              }
            } 
          }
        `,
      }),
      transformResponse: (response: any) => {
        if (response.customerAccessTokenCreate?.customerUserErrors.length > 0) {
          throw new Error(
            response.customerAccessTokenCreate.customerUserErrors[0].message
          );
        }
      },
      invalidatesTags: ["Customer"],
      async onQueryStarted(arg: any, lifecycleApi: any) {
        try {
          console.log(lifecycleApi);
          const response = await lifecycleApi.queryFulfilled;

          const cat =
            response.data.customerAccessTokenCreate.customerAccessToken;
        } catch (error: any) {
          const errorMessage = error.error
            ? error.error.message
            : error.message;
          lifecycleApi.dispatch(
            showAlert({
              alertMessage: errorMessage,
              alertType: "danger",
            })
          );
          setTimeout(() => {
            lifecycleApi.dispatch(clearAlert({}));
          }, 2000);
          console.log("some error occurred creating access token", error);  
        }
      },
    }),
  }),
});

export const selectAuthData = (state: RootState) => state.auth;

export const { setCustomerAccessToken, setCustomerData, logoutCustomer } =
  authSlice.actions;

export const {
  useCreateCustomerTokenMutation,
} = extendedApi;

export default authSlice.reducer;

errorLogger.ts

import { isRejected} from "@reduxjs/toolkit";
import type { MiddlewareAPI, Middleware } from "@reduxjs/toolkit";

/**
 * Log a warning and show a toast!
 */
export const rtkQueryErrorLogger: Middleware =
  (api: MiddlewareAPI) => (next) => (action) => {
    // RTK Query uses `createAsyncThunk` from redux-toolkit under the hood, so we're able to utilize these matchers!
    if (isRejected(action)) {
      console.log("We got a rejected action!");
    }

    return next(action);
  };

store.ts

import { configureStore, combineReducers } from "@reduxjs/toolkit";
import localSliceReducer from "./features/local/localSlice";
import authSliceReducer from "./features/auth/authSlice";
import { apiSlice } from "./features/api/apiSlice";
import alertsSliceReducer from "./features/alerts/alertsSlice";
import { rtkQueryErrorLogger } from "./middleware/errorLogger";

// Create the root reducer separately so we can extract the RootState type
const rootReducer = combineReducers({
  local: localSliceReducer,
  auth: authSliceReducer,
  alerts: alertsSliceReducer,
  [apiSlice.reducerPath]: apiSlice.reducer,
});

export const makeStore = (preloadedState: any) => {
  return configureStore({
    reducer: rootReducer,
    preloadedState,
    middleware: (buildGetDefaultMiddleware: any) =>
      buildGetDefaultMiddleware()
        .concat(apiSlice.middleware)
        .concat(rtkQueryErrorLogger),
  });
};

// Infer the type of makeStore
export type AppStore = ReturnType<typeof makeStore>;
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<AppStore["getState"]>;
export type AppDispatch = AppStore["dispatch"];
markerikson commented 3 weeks ago

If you're going to file an issue, you need to actually give some meaningful description of what's happening, and provide examples that reproduce the problem. We can't do anything with this little info.

anthonyss09 commented 3 weeks ago

I created this by accident, it's my first time creating an issue and I was figuring out the formatting. Should I edit this issue with all the information or start a new one?

markerikson commented 3 weeks ago

Ah, okay. Fill this issue and leave another comment when you're done.

anthonyss09 commented 3 weeks ago

Thank you. I hope i've provided all the necessary information.

markerikson commented 3 weeks ago

What's the actual error shown by Next? Can you include a screenshot of that?

anthonyss09 commented 3 weeks ago

It depends on the error I receive from server. Here is one specific response where email & password are incorrect. Image

So it doesn't break my code, but i still get the error alert like so... Image

And because It still says unhandled upon inspection, I assume this is not the expected behavior.

markerikson commented 3 weeks ago

Hmm. Not sure what about this qualifies as "unhandled", or what's triggering Next's logic specifically here.

anthonyss09 commented 3 weeks ago

Here is the error object in the console. It has a property isUnhandledError and it remains true. Just for kicks I set it to false in the catch block but at that point maybe next has already read this property. Maybe the error object sent back from shopify storefront api is unfamiliar to rtk and the isUnhandledError is not being set or read properly. That's the only thought i'm left with right now.

Image

markerikson commented 3 weeks ago

Oh, wait. That's probably because you're doing this in the transformResponse option:

      transformResponse: (response: any) => {
        if (response.customerAccessTokenCreate?.customerUserErrors.length > 0) {
          throw new Error(
            response.customerAccessTokenCreate.customerUserErrors[0].message
          );
        }
      },

that would definitely make this "unhandled". transformResponse shouldn't be throwing errors.

anthonyss09 commented 3 weeks ago

I do that because shopify will return a response with customUserErrors array that doesn't throw or register as an error. So in the instance of invalid credentials I will receive a success response which I can interogate and throw a client side error when calling the hook, but I wanted it all in the same place so I throw the error in transform response. But other server errors such as 'too many attempts' will come back as error and the same flow with occur.

I just commented out the transform response and once I hit the limit on attempts the same issue occurs upon receiving the error.

anthonyss09 commented 3 weeks ago

I think I might be able to restructure my base query & properly rewrite my endpoint request. This link hopefully will fix me up, i'll report back.

https://redux-toolkit.js.org/rtk-query/usage/customizing-queries#graphql-basequery

anthonyss09 commented 2 weeks ago

First of all you were right of course to mention the throw in transform response. I thought I would be catching it once queryFulfilled, but that throw is out of the range of the try/catch in onQueryStarted. So now I inspect the response for customerUserErrors in onQueryStarted and throw the error there if customerUserErrors has length.

For my uncaught error responses problem I found a stack question that lead to my solve. https://stackoverflow.com/questions/74101408/mismatch-of-types-for-rtk-query-graphql-request-and-graphql-request

I stopped creating graphQLClient from graphql-request package and just added the url to graphqlRequestBaseQuery. GraphQLClient from graphql-request was throwing an error I wasn't handling.

The updated files below:

apiSlice.ts

import { createApi } from "@reduxjs/toolkit/query/react";
import { graphqlRequestBaseQuery } from "@rtk-query/graphql-request-base-query";

export const apiSlice = createApi({
  reducerPath: "api",
  baseQuery: graphqlRequestBaseQuery({
    url: `https://${process.env.NEXT_PUBLIC_SHOP_NAME}.myshopify.com/api/${process.env.NEXT_PUBLIC_VERSION}/graphql.json`,

    prepareHeaders: (headers, { getState }) => {
      headers.set("Accept", "application/json");
      headers.set("Content-Type", "application/json");
      headers.set(
        "X-Shopify-Storefront-Access-Token",
        process.env.NEXT_PUBLIC_SHOPIFY_ACCESS_TOKEN
          ? process.env.NEXT_PUBLIC_SHOPIFY_ACCESS_TOKEN
          : ""
      );
      return headers;
    },
  }),

  tagTypes: ["Cart", "Customer"],
  endpoints: (build) => ({}),
});

authSlice.ts

import { createSlice } from "@reduxjs/toolkit";
import type { RootState } from "../../store";
import { apiSlice } from "../api/apiSlice";
import {
  createTokenArgs,
} from "./types";
import { showAlert, clearAlert } from "../alerts/alertsSlice";
import { gql} from "graphql-request";

let customerAccessToken;
if (typeof localStorage !== "undefined") {
  const localToken = localStorage.getItem("blissCustomerAccessToken");
  if (localToken !== null) {
    customerAccessToken = JSON.parse(localToken);
  } else {
    customerAccessToken = null;
  }
} else {
  customerAccessToken = null;
}
const initialState = {
  customerAccessToken,
  customer: { firstName: "", id: null },
};

const authSlice = createSlice({
  name: "auth",
  initialState,

  reducers: {
    setCustomerAccessToken(state, action) {
      state.customerAccessToken = action.payload;
    },
    setCustomerData(state, action) {
      state.customer = action.payload;
    },
    logoutCustomer(state, action) {
      state.customer = { firstName: "", id: null };
      state.customerAccessToken = null;
    },
  },
});

export const extendedApi = apiSlice.injectEndpoints({
  endpoints: (build: any) => ({
    createCustomerToken: build.mutation({
      query: (arg: createTokenArgs) => ({
        document: gql`
          mutation {
            customerAccessTokenCreate(input: {email: "${arg.email}", password: "${arg.password}"})
            {
              customerAccessToken {
                accessToken
              }
              customerUserErrors {
                message
              }
            } 
          }
        `,
      }),

      invalidatesTags: ["Customer"],
      async onQueryStarted(arg: any, lifecycleApi: any) {
        try {
          const response = await lifecycleApi.queryFulfilled;
          if (
            response.data.customerAccessTokenCreate.customerUserErrors.length 
          ) {
            throw new Error(
              response.data.customerAccessTokenCreate.customerUserErrors[0].message
            );
          }
        } catch (error: any) {
          const errorMessage = error.error
            ? error.error.message
            : error.message;
          lifecycleApi.dispatch(
            showAlert({
              alertMessage: errorMessage,
              alertType: "danger",
            })
          );
          setTimeout(() => {
            lifecycleApi.dispatch(clearAlert({}));
          }, 2000);
          console.log("some error occured creating access token", error);
        }
      },
    }),   
  }),
});

export const selectAuthData = (state: RootState) => state.auth;

export const { setCustomerAccessToken, setCustomerData, logoutCustomer } =
  authSlice.actions;

export const {
  useCreateCustomerTokenMutation,
} = extendedApi;

export default authSlice.reducer;