diff --git a/packages/toolkit/src/createAsyncThunk.ts b/packages/toolkit/src/createAsyncThunk.ts index f384ab9af2..7f93fe2792 100644 --- a/packages/toolkit/src/createAsyncThunk.ts +++ b/packages/toolkit/src/createAsyncThunk.ts @@ -1,4 +1,4 @@ -import type { Dispatch, UnknownAction } from 'redux' +import type { Action, Dispatch, UnknownAction } from 'redux' import type { PayloadAction, ActionCreatorWithPreparedPayload, @@ -114,6 +114,10 @@ export const miniSerializeError = (value: any): SerializedError => { return { message: String(value) } } +export class DispatchError { + constructor(public readonly cause: unknown) {} +} + export type AsyncThunkConfig = { state?: unknown dispatch?: Dispatch @@ -617,16 +621,20 @@ export const createAsyncThunk = /* @__PURE__ */ (() => { } abortController.signal.addEventListener('abort', abortHandler) }) - dispatch( - pending( - requestId, - arg, - options?.getPendingMeta?.( - { requestId, arg }, - { getState, extra }, - ), - ) as any, - ) + try { + dispatch( + pending( + requestId, + arg, + options?.getPendingMeta?.( + { requestId, arg }, + { getState, extra }, + ), + ) as any, + ) + } catch (err) { + throw new DispatchError(err) + } finalAction = await Promise.race([ abortedPromise, Promise.resolve( @@ -658,6 +666,9 @@ export const createAsyncThunk = /* @__PURE__ */ (() => { }), ]) } catch (err) { + if (err instanceof DispatchError) { + throw err.cause + } finalAction = err instanceof RejectWithValue ? rejected(null, requestId, arg, err.payload, err.meta) diff --git a/packages/toolkit/src/query/tests/matchers.test.tsx b/packages/toolkit/src/query/tests/matchers.test.tsx index 5b26740e98..1c0b6c6432 100644 --- a/packages/toolkit/src/query/tests/matchers.test.tsx +++ b/packages/toolkit/src/query/tests/matchers.test.tsx @@ -3,6 +3,7 @@ import { hookWaitFor, setupApiStore, } from '@internal/tests/utils/helpers' +import type { UnknownAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' import { act, renderHook } from '@testing-library/react' @@ -239,3 +240,34 @@ test('inferred types', () => { }, }) }) + +describe('errors in reducers are not swallowed', () => { + const faultyStoreFor = (matcher: (action: UnknownAction) => boolean) => + setupApiStore(api, { + ...actionsReducer, + faultyReducer(state = null, action: UnknownAction) { + if (matcher(action)) { + throw new Error('reducer error') + } + return state + }, + }) + test('pending action reducer errors should be thrown', async () => { + const storeRef = faultyStoreFor(api.endpoints.querySuccess.matchPending) + await expect( + storeRef.store.dispatch(querySuccess2.initiate({} as any)), + ).rejects.toThrow('reducer error') + }) + test('fulfilled action reducer errors should be thrown', async () => { + const storeRef = faultyStoreFor(api.endpoints.querySuccess.matchFulfilled) + await expect( + storeRef.store.dispatch(querySuccess2.initiate({} as any)), + ).rejects.toThrow('reducer error') + }) + test('rejected action reducer errors should be thrown', async () => { + const storeRef = faultyStoreFor(api.endpoints.queryFail.matchRejected) + await expect( + storeRef.store.dispatch(queryFail.initiate({} as any)), + ).rejects.toThrow('reducer error') + }) +}) diff --git a/packages/toolkit/src/tests/createAsyncThunk.test.ts b/packages/toolkit/src/tests/createAsyncThunk.test.ts index ef6ae71838..c344e99878 100644 --- a/packages/toolkit/src/tests/createAsyncThunk.test.ts +++ b/packages/toolkit/src/tests/createAsyncThunk.test.ts @@ -991,3 +991,42 @@ describe('meta', () => { expect(thunk.fulfilled.type).toBe('a/fulfilled') }) }) + +describe('reducer errors will be rethrown', () => { + const asyncThunk = createAsyncThunk('test', async (shouldThrow: boolean) => { + await delay(100) + if (shouldThrow) { + throw new Error('Error in reducer') + } + return 'Success' + }) + const faultStoreForType = (type: 'pending' | 'fulfilled' | 'rejected') => + configureStore({ + reducer(state = null, action) { + // obviously reducers shouldn't ever throw - but legitimate errors do happen, with typos for example + if (asyncThunk[type].match(action)) { + throw new Error('Error in reducer') + } + return state + }, + }) + // TODO: this breaks our promise that not unwrapping will never throw - do we care? + it('rethrows errors found when dispatching pending action', async () => { + const store = faultStoreForType('pending') + await expect(store.dispatch(asyncThunk(false))).rejects.toThrowError( + 'Error in reducer', + ) + }) + it('rethrows errors found when dispatching fulfilled action', async () => { + const store = faultStoreForType('fulfilled') + await expect(store.dispatch(asyncThunk(false))).rejects.toThrowError( + 'Error in reducer', + ) + }) + it('rethrows errors found when dispatching rejected action', async () => { + const store = faultStoreForType('rejected') + await expect(store.dispatch(asyncThunk(true))).rejects.toThrowError( + 'Error in reducer', + ) + }) +})